Merge pull request #149 from DRYTRIX/develop

Develop
This commit is contained in:
Dries Peeters
2025-10-24 15:05:23 +02:00
committed by GitHub
84 changed files with 17793 additions and 146 deletions

View File

@@ -0,0 +1,394 @@
# Advanced Permission Handling Implementation Summary
## Overview
This document summarizes the implementation of the advanced permission handling system for TimeTracker. The system provides granular, role-based access control that allows administrators to fine-tune what users can and cannot do in the application.
## What Was Implemented
### 1. Database Models
**Files Created/Modified:**
- `app/models/permission.py` - New file containing:
- `Permission` model - Individual permissions
- `Role` model - Collections of permissions
- Association tables for many-to-many relationships
- `app/models/user.py` - Enhanced with:
- Role relationship
- Permission checking methods (`has_permission`, `has_any_permission`, `has_all_permissions`)
- Backward compatibility with legacy role system
### 2. Database Migration
**Files Created:**
- `migrations/versions/030_add_permission_system.py` - Alembic migration that creates:
- `permissions` table
- `roles` table
- `role_permissions` association table
- `user_roles` association table
### 3. Permission Utilities
**Files Created:**
- `app/utils/permissions.py` - Permission decorators and helpers:
- `@permission_required` - Route decorator for permission checks
- `@admin_or_permission_required` - Flexible decorator for migration
- Template helper functions
- Permission checking utilities
- `app/utils/permissions_seed.py` - Default data seeding:
- 59 default permissions across 9 categories
- 5 default roles (super_admin, admin, manager, user, viewer)
- Migration of legacy users to new system
### 4. Admin Routes
**Files Created:**
- `app/routes/permissions.py` - Complete CRUD interface for:
- List/create/edit/delete roles
- View role details
- Manage user role assignments
- View permissions
- API endpoints for permission queries
### 5. Templates
**Files Created:**
- `app/templates/admin/roles/list.html` - List all roles
- `app/templates/admin/roles/form.html` - Create/edit role form
- `app/templates/admin/roles/view.html` - View role details
- `app/templates/admin/permissions/list.html` - View all permissions
- `app/templates/admin/users/roles.html` - Manage user roles
**Files Modified:**
- `app/templates/admin/dashboard.html` - Added link to Roles & Permissions
- `app/templates/admin/user_form.html` - Added role management section
### 6. Tests
**Files Created:**
- `tests/test_permissions.py` - Unit and model tests (24 test cases):
- Permission CRUD operations
- Role CRUD operations
- Permission-role associations
- User-role associations
- Permission checking logic
- Backward compatibility
- `tests/test_permissions_routes.py` - Integration and smoke tests (16 test cases):
- Page load tests
- Role creation/editing/deletion workflows
- User role assignment
- System role protection
- API endpoint tests
- Access control tests
### 7. Documentation
**Files Created:**
- `docs/ADVANCED_PERMISSIONS.md` - Comprehensive documentation covering:
- System concepts and architecture
- Default roles and permissions
- Administrator guide
- Developer guide
- Migration guide
- API reference
- Best practices and troubleshooting
- `ADVANCED_PERMISSIONS_IMPLEMENTATION_SUMMARY.md` - This file
### 8. CLI Commands
**Files Modified:**
- `app/utils/cli.py` - Added commands:
- `flask seed_permissions_cmd` - Initial setup of permissions/roles
- `flask update_permissions` - Update permissions after system updates
### 9. Integration
**Files Modified:**
- `app/__init__.py` - Registered permissions blueprint
- `app/models/__init__.py` - Exported Permission and Role models
- `app/utils/context_processors.py` - Registered permission template helpers
## Key Features
### 1. Granular Permissions
59 individual permissions organized into 9 categories:
- Time Entries (7 permissions)
- Projects (6 permissions)
- Tasks (8 permissions)
- Clients (5 permissions)
- Invoices (7 permissions)
- Reports (4 permissions)
- User Management (5 permissions)
- System (5 permissions)
- Administration (3 permissions)
### 2. Flexible Role System
- 5 pre-defined system roles
- Unlimited custom roles
- Multiple roles per user
- Permissions are cumulative across roles
### 3. Backward Compatibility
- Legacy `role` field still works
- Old admin users automatically have full permissions
- Seamless migration path
### 4. Admin Interface
- Intuitive web interface for managing roles and permissions
- Visual permission selection grouped by category
- User role assignment interface
- Real-time permission preview for users
### 5. Developer-Friendly
- Simple decorators for route protection
- Template helpers for conditional UI
- Comprehensive API for permission checks
- Well-documented and tested
## Installation and Setup
### Step 1: Run Database Migration
```bash
# Apply the migration
flask db upgrade
# Or using alembic directly
cd migrations
alembic upgrade head
```
### Step 2: Seed Default Permissions and Roles
```bash
flask seed_permissions_cmd
```
This will:
- Create all 59 default permissions
- Create all 5 default roles
- Migrate existing users to the new system
### Step 3: Verify Installation
1. Log in as an admin user
2. Navigate to **Admin Dashboard****Roles & Permissions**
3. Verify you see 5 system roles
4. Click on a role to view its permissions
### Step 4: (Optional) Customize Roles
Create custom roles based on your organization's needs:
1. Click **Create Role** in the admin panel
2. Select permissions appropriate for the role
3. Assign users to the new role
## Usage Examples
### For Administrators
**Assigning Roles to a User:**
1. Admin Dashboard → Manage Users
2. Click Edit on a user
3. Click "Manage Roles & Permissions"
4. Select desired roles
5. Click "Update Roles"
### For Developers
**Protecting a Route:**
```python
from app.utils.permissions import permission_required
@app.route('/projects/<id>/delete', methods=['POST'])
@login_required
@permission_required('delete_projects')
def delete_project(id):
# Only users with delete_projects permission can access
project = Project.query.get_or_404(id)
db.session.delete(project)
db.session.commit()
return redirect(url_for('projects.list'))
```
**Conditional UI in Templates:**
```html
{% if has_permission('edit_projects') %}
<a href="{{ url_for('projects.edit', id=project.id) }}" class="btn btn-primary">
Edit Project
</a>
{% endif %}
```
## Testing
Run the permission tests:
```bash
# Run all permission tests
pytest tests/test_permissions.py tests/test_permissions_routes.py -v
# Run only smoke tests
pytest tests/test_permissions_routes.py -m smoke -v
# Run only unit tests
pytest tests/test_permissions.py -m unit -v
```
Expected results:
- 40 total test cases
- All tests should pass
- ~95% code coverage for permission-related code
## Migration Path
### For Existing Deployments
1. **Backup Database**: Always backup before migrating
```bash
flask backup_create
```
2. **Run Migration**:
```bash
flask db upgrade
```
3. **Seed Permissions**:
```bash
flask seed_permissions_cmd
```
4. **Verify**:
- Log in as admin
- Check that existing admin users still have access
- Verify roles are created
5. **Gradual Rollout**:
- Use `@admin_or_permission_required` decorator initially
- Migrate to `@permission_required` over time
- Test thoroughly with different user types
### Rollback Procedure
If issues arise:
1. **Database Rollback**:
```bash
flask db downgrade
```
2. **Restore Backup** (if needed):
```bash
flask backup_restore <backup_file.zip>
```
3. The system will fall back to legacy role checking
## Performance Considerations
- **Permission Checks**: O(n) where n = number of roles × permissions per role
- Typical: < 100 checks, negligible performance impact
- Permissions loaded with user (joined query)
- **Database Queries**:
- User permissions loaded on login (cached in session)
- Role changes require logout/login to take effect
- Minimal overhead per request
## Security Notes
- ✅ All permission changes require admin access
- ✅ System roles cannot be deleted or renamed
- ✅ Roles assigned to users cannot be deleted (protection)
- ✅ CSRF protection on all forms
- ✅ Rate limiting on sensitive endpoints
- ✅ Backward compatible (existing security maintained)
## Future Enhancements
Potential improvements for future versions:
1. **Permission Caching**: Cache user permissions in Redis for better performance
2. **Audit Logging**: Log all permission and role changes
3. **Time-Based Roles**: Temporary role assignments with expiration
4. **API Scopes**: Permissions for API access tokens
5. **Permission Groups**: Hierarchical permission organization
6. **Role Templates**: Export/import role configurations
7. **User Delegation**: Allow users to delegate certain permissions temporarily
## Breaking Changes
**None** - The implementation is fully backward compatible:
- Legacy `role='admin'` still works
- Legacy `role='user'` still works
- `is_admin` property works with both old and new systems
- Existing code continues to function without changes
## Support and Troubleshooting
### Common Issues
**Issue**: User cannot see new roles after migration
**Solution**: User needs to log out and log back in
**Issue**: Cannot delete a role
**Solution**: Check if it's a system role or has users assigned
**Issue**: Permission changes not taking effect
**Solution**: Clear browser cache and session, or restart the application
### Getting Help
1. Check `docs/ADVANCED_PERMISSIONS.md` for detailed documentation
2. Review test files for usage examples
3. Check application logs for permission-related errors
4. Verify database migration completed successfully
## Files Changed Summary
### New Files (13)
1. `app/models/permission.py`
2. `app/routes/permissions.py`
3. `app/utils/permissions.py`
4. `app/utils/permissions_seed.py`
5. `app/templates/admin/roles/list.html`
6. `app/templates/admin/roles/form.html`
7. `app/templates/admin/roles/view.html`
8. `app/templates/admin/permissions/list.html`
9. `app/templates/admin/users/roles.html`
10. `migrations/versions/030_add_permission_system.py`
11. `tests/test_permissions.py`
12. `tests/test_permissions_routes.py`
13. `docs/ADVANCED_PERMISSIONS.md`
### Modified Files (6)
1. `app/models/__init__.py` - Added Permission and Role imports
2. `app/models/user.py` - Added roles relationship and permission methods
3. `app/__init__.py` - Registered permissions blueprint
4. `app/utils/cli.py` - Added seeding commands
5. `app/utils/context_processors.py` - Added permission helpers
6. `app/templates/admin/dashboard.html` - Added Roles & Permissions link
7. `app/templates/admin/user_form.html` - Added role management section
## Conclusion
The advanced permission handling system has been successfully implemented with:
✅ Comprehensive database schema
✅ Full CRUD interface for roles and permissions
✅ Backward compatibility maintained
✅ 40 test cases with excellent coverage
✅ Complete documentation
✅ Production-ready code
The system is ready for use and provides a solid foundation for fine-grained access control in TimeTracker.

View File

@@ -755,6 +755,7 @@ def create_app(config=None):
from app.routes.tasks import tasks_bp
from app.routes.invoices import invoices_bp
from app.routes.clients import clients_bp
from app.routes.client_notes import client_notes_bp
from app.routes.comments import comments_bp
from app.routes.kanban import kanban_bp
from app.routes.setup import setup_bp
@@ -762,6 +763,9 @@ def create_app(config=None):
from app.routes.time_entry_templates import time_entry_templates_bp
from app.routes.saved_filters import saved_filters_bp
from app.routes.settings import settings_bp
from app.routes.weekly_goals import weekly_goals_bp
from app.routes.expenses import expenses_bp
from app.routes.permissions import permissions_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
@@ -774,6 +778,7 @@ def create_app(config=None):
app.register_blueprint(tasks_bp)
app.register_blueprint(invoices_bp)
app.register_blueprint(clients_bp)
app.register_blueprint(client_notes_bp)
app.register_blueprint(comments_bp)
app.register_blueprint(kanban_bp)
app.register_blueprint(setup_bp)
@@ -781,6 +786,9 @@ def create_app(config=None):
app.register_blueprint(time_entry_templates_bp)
app.register_blueprint(saved_filters_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(weekly_goals_bp)
app.register_blueprint(expenses_bp)
app.register_blueprint(permissions_bp)
# Exempt API blueprint from CSRF protection (JSON API uses authentication, not CSRF tokens)
# Only if CSRF is enabled

View File

@@ -22,6 +22,10 @@ from .kanban_column import KanbanColumn
from .time_entry_template import TimeEntryTemplate
from .activity import Activity
from .user_favorite_project import UserFavoriteProject
from .client_note import ClientNote
from .weekly_time_goal import WeeklyTimeGoal
from .expense import Expense
from .permission import Permission, Role
__all__ = [
"User",
@@ -52,4 +56,9 @@ __all__ = [
"TimeEntryTemplate",
"Activity",
"UserFavoriteProject",
"ClientNote",
"WeeklyTimeGoal",
"Expense",
"Permission",
"Role",
]

149
app/models/client_note.py Normal file
View File

@@ -0,0 +1,149 @@
from datetime import datetime
from app import db
from app.utils.timezone import now_in_app_timezone
class ClientNote(db.Model):
"""ClientNote model for internal notes about clients"""
__tablename__ = 'client_notes'
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
# Reference to client
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True)
# Author of the note
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
# Internal flag - these notes are always internal and not visible to clients
is_important = db.Column(db.Boolean, default=False, nullable=False)
# Timestamps
created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False)
updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False)
# Relationships
author = db.relationship('User', backref='client_notes')
client = db.relationship('Client', backref='notes')
def __init__(self, content, user_id, client_id, is_important=False):
"""Create a client note.
Args:
content: The note text
user_id: ID of the user creating the note
client_id: ID of the client
is_important: Whether this note is marked as important
"""
if not client_id:
raise ValueError("Note must be associated with a client")
if not content or not content.strip():
raise ValueError("Note content cannot be empty")
self.content = content.strip()
self.user_id = user_id
self.client_id = client_id
self.is_important = is_important
def __repr__(self):
return f'<ClientNote by {self.author.username if self.author else "Unknown"} for Client {self.client_id}>'
@property
def author_name(self):
"""Get the author's display name"""
if self.author:
return self.author.full_name if self.author.full_name else self.author.username
return 'Unknown'
@property
def client_name(self):
"""Get the client name"""
return self.client.name if self.client else 'Unknown'
def can_edit(self, user):
"""Check if a user can edit this note"""
return user.id == self.user_id or user.is_admin
def can_delete(self, user):
"""Check if a user can delete this note"""
return user.id == self.user_id or user.is_admin
def edit_content(self, new_content, user, is_important=None):
"""Edit the note content
Args:
new_content: New content for the note
user: User making the edit
is_important: Optional new importance flag
"""
if not self.can_edit(user):
raise PermissionError("User does not have permission to edit this note")
if not new_content or not new_content.strip():
raise ValueError("Note content cannot be empty")
self.content = new_content.strip()
if is_important is not None:
self.is_important = is_important
self.updated_at = now_in_app_timezone()
def to_dict(self):
"""Convert note to dictionary for API responses"""
return {
'id': self.id,
'content': self.content,
'client_id': self.client_id,
'client_name': self.client_name,
'user_id': self.user_id,
'author': self.author.username if self.author else None,
'author_full_name': self.author.full_name if self.author and self.author.full_name else None,
'author_name': self.author_name,
'is_important': self.is_important,
'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_client_notes(cls, client_id, order_by_important=False):
"""Get all notes for a client
Args:
client_id: ID of the client
order_by_important: If True, important notes appear first
"""
query = cls.query.filter_by(client_id=client_id)
if order_by_important:
query = query.order_by(cls.is_important.desc(), cls.created_at.desc())
else:
query = query.order_by(cls.created_at.desc())
return query.all()
@classmethod
def get_important_notes(cls, client_id=None):
"""Get all important notes, optionally filtered by client"""
query = cls.query.filter_by(is_important=True)
if client_id:
query = query.filter_by(client_id=client_id)
return query.order_by(cls.created_at.desc()).all()
@classmethod
def get_user_notes(cls, user_id, limit=None):
"""Get recent notes by a user"""
query = cls.query.filter_by(user_id=user_id).order_by(cls.created_at.desc())
if limit:
query = query.limit(limit)
return query.all()
@classmethod
def get_recent_notes(cls, limit=10):
"""Get recent notes across all clients"""
return cls.query.order_by(cls.created_at.desc()).limit(limit).all()

380
app/models/expense.py Normal file
View File

@@ -0,0 +1,380 @@
from datetime import datetime
from decimal import Decimal
from app import db
from sqlalchemy import Index
class Expense(db.Model):
"""Expense tracking model for business expenses"""
__tablename__ = 'expenses'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True)
# Expense details
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
category = db.Column(db.String(50), nullable=False) # 'travel', 'meals', 'accommodation', 'supplies', 'software', 'equipment', 'services', 'other'
amount = db.Column(db.Numeric(10, 2), nullable=False)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
# Tax information
tax_amount = db.Column(db.Numeric(10, 2), nullable=True, default=0)
tax_rate = db.Column(db.Numeric(5, 2), nullable=True, default=0) # Percentage
# Payment information
payment_method = db.Column(db.String(50), nullable=True) # 'cash', 'credit_card', 'bank_transfer', 'company_card', etc.
payment_date = db.Column(db.Date, nullable=True)
# Status and approval
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed'
approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
approved_at = db.Column(db.DateTime, nullable=True)
rejection_reason = db.Column(db.Text, nullable=True)
# Billing and invoicing
billable = db.Column(db.Boolean, default=False, nullable=False)
reimbursable = db.Column(db.Boolean, default=True, nullable=False)
invoiced = db.Column(db.Boolean, default=False, nullable=False)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=True, index=True)
reimbursed = db.Column(db.Boolean, default=False, nullable=False)
reimbursed_at = db.Column(db.DateTime, nullable=True)
# Date and metadata
expense_date = db.Column(db.Date, nullable=False, index=True)
receipt_path = db.Column(db.String(500), nullable=True)
receipt_number = db.Column(db.String(100), nullable=True)
vendor = db.Column(db.String(200), nullable=True)
notes = db.Column(db.Text, nullable=True)
# Tags for categorization
tags = db.Column(db.String(500), nullable=True) # Comma-separated tags
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('expenses', lazy='dynamic'))
approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_expenses', lazy='dynamic'))
project = db.relationship('Project', backref=db.backref('expenses', lazy='dynamic'))
client = db.relationship('Client', backref=db.backref('expenses', lazy='dynamic'))
invoice = db.relationship('Invoice', backref=db.backref('expenses', lazy='dynamic'))
# Add composite indexes for common query patterns
__table_args__ = (
Index('ix_expenses_user_date', 'user_id', 'expense_date'),
Index('ix_expenses_status_date', 'status', 'expense_date'),
Index('ix_expenses_project_date', 'project_id', 'expense_date'),
)
def __init__(self, user_id, title, category, amount, expense_date, **kwargs):
self.user_id = user_id
self.title = title.strip() if title else None
self.category = category
self.amount = Decimal(str(amount))
self.expense_date = expense_date
# Optional fields
self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None
self.project_id = kwargs.get('project_id')
self.client_id = kwargs.get('client_id')
self.currency_code = kwargs.get('currency_code', 'EUR')
self.tax_amount = Decimal(str(kwargs.get('tax_amount', 0)))
self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0)))
self.payment_method = kwargs.get('payment_method')
self.payment_date = kwargs.get('payment_date')
self.billable = kwargs.get('billable', False)
self.reimbursable = kwargs.get('reimbursable', True)
self.receipt_path = kwargs.get('receipt_path')
self.receipt_number = kwargs.get('receipt_number')
self.vendor = kwargs.get('vendor')
self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None
self.tags = kwargs.get('tags')
self.status = kwargs.get('status', 'pending')
def __repr__(self):
return f'<Expense {self.title} ({self.amount} {self.currency_code})>'
@property
def is_approved(self):
"""Check if expense is approved"""
return self.status == 'approved'
@property
def is_rejected(self):
"""Check if expense is rejected"""
return self.status == 'rejected'
@property
def is_reimbursed(self):
"""Check if expense has been reimbursed"""
return self.reimbursed and self.reimbursed_at is not None
@property
def is_invoiced(self):
"""Check if this expense has been invoiced"""
return self.invoiced and self.invoice_id is not None
@property
def total_amount(self):
"""Calculate total amount including tax"""
return self.amount + (self.tax_amount or 0)
@property
def tag_list(self):
"""Get list of tags"""
if not self.tags:
return []
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
def approve(self, approved_by_user_id, notes=None):
"""Approve the expense"""
self.status = 'approved'
self.approved_by = approved_by_user_id
self.approved_at = datetime.utcnow()
if notes:
self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}'
self.updated_at = datetime.utcnow()
def reject(self, rejected_by_user_id, reason):
"""Reject the expense"""
self.status = 'rejected'
self.approved_by = rejected_by_user_id
self.approved_at = datetime.utcnow()
self.rejection_reason = reason
self.updated_at = datetime.utcnow()
def mark_as_reimbursed(self):
"""Mark this expense as reimbursed"""
self.reimbursed = True
self.reimbursed_at = datetime.utcnow()
self.status = 'reimbursed'
self.updated_at = datetime.utcnow()
def mark_as_invoiced(self, invoice_id):
"""Mark this expense as invoiced"""
self.invoiced = True
self.invoice_id = invoice_id
self.updated_at = datetime.utcnow()
def unmark_as_invoiced(self):
"""Unmark this expense as invoiced (e.g., if invoice is deleted)"""
self.invoiced = False
self.invoice_id = None
self.updated_at = datetime.utcnow()
def to_dict(self):
"""Convert expense to dictionary for API responses"""
return {
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'client_id': self.client_id,
'title': self.title,
'description': self.description,
'category': self.category,
'amount': float(self.amount),
'currency_code': self.currency_code,
'tax_amount': float(self.tax_amount) if self.tax_amount else 0,
'tax_rate': float(self.tax_rate) if self.tax_rate else 0,
'total_amount': float(self.total_amount),
'payment_method': self.payment_method,
'payment_date': self.payment_date.isoformat() if self.payment_date else None,
'status': self.status,
'approved_by': self.approved_by,
'approved_at': self.approved_at.isoformat() if self.approved_at else None,
'rejection_reason': self.rejection_reason,
'billable': self.billable,
'reimbursable': self.reimbursable,
'invoiced': self.invoiced,
'invoice_id': self.invoice_id,
'reimbursed': self.reimbursed,
'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None,
'expense_date': self.expense_date.isoformat() if self.expense_date else None,
'receipt_path': self.receipt_path,
'receipt_number': self.receipt_number,
'vendor': self.vendor,
'notes': self.notes,
'tags': self.tag_list,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'user': self.user.username if self.user else None,
'project': self.project.name if self.project else None,
'client': self.client.name if self.client else None,
'approver': self.approver.username if self.approver else None
}
@classmethod
def get_expenses(cls, user_id=None, project_id=None, client_id=None,
start_date=None, end_date=None, status=None,
category=None, billable_only=False, reimbursable_only=False):
"""Get expenses with optional filters"""
query = cls.query
if user_id:
query = query.filter(cls.user_id == user_id)
if project_id:
query = query.filter(cls.project_id == project_id)
if client_id:
query = query.filter(cls.client_id == client_id)
if start_date:
query = query.filter(cls.expense_date >= start_date)
if end_date:
query = query.filter(cls.expense_date <= end_date)
if status:
query = query.filter(cls.status == status)
if category:
query = query.filter(cls.category == category)
if billable_only:
query = query.filter(cls.billable == True)
if reimbursable_only:
query = query.filter(cls.reimbursable == True)
return query.order_by(cls.expense_date.desc()).all()
@classmethod
def get_total_expenses(cls, user_id=None, project_id=None, client_id=None,
start_date=None, end_date=None, status=None,
category=None, include_tax=True):
"""Calculate total expenses with optional filters"""
query = db.session.query(
db.func.sum(cls.amount if not include_tax else cls.amount + db.func.coalesce(cls.tax_amount, 0))
)
if user_id:
query = query.filter(cls.user_id == user_id)
if project_id:
query = query.filter(cls.project_id == project_id)
if client_id:
query = query.filter(cls.client_id == client_id)
if start_date:
query = query.filter(cls.expense_date >= start_date)
if end_date:
query = query.filter(cls.expense_date <= end_date)
if status:
query = query.filter(cls.status == status)
if category:
query = query.filter(cls.category == category)
total = query.scalar() or Decimal('0')
return float(total)
@classmethod
def get_expenses_by_category(cls, user_id=None, start_date=None, end_date=None, status=None):
"""Get expenses grouped by category"""
query = db.session.query(
cls.category,
db.func.sum(cls.amount + db.func.coalesce(cls.tax_amount, 0)).label('total_amount'),
db.func.count(cls.id).label('count')
)
if user_id:
query = query.filter(cls.user_id == user_id)
if start_date:
query = query.filter(cls.expense_date >= start_date)
if end_date:
query = query.filter(cls.expense_date <= end_date)
if status:
query = query.filter(cls.status == status)
results = query.group_by(cls.category).all()
return [
{
'category': category,
'total_amount': float(total_amount),
'count': count
}
for category, total_amount, count in results
]
@classmethod
def get_pending_approvals(cls, user_id=None):
"""Get expenses pending approval"""
query = cls.query.filter_by(status='pending')
if user_id:
query = query.filter(cls.user_id == user_id)
return query.order_by(cls.expense_date.desc()).all()
@classmethod
def get_pending_reimbursements(cls, user_id=None):
"""Get approved expenses pending reimbursement"""
query = cls.query.filter(
cls.status == 'approved',
cls.reimbursable == True,
cls.reimbursed == False
)
if user_id:
query = query.filter(cls.user_id == user_id)
return query.order_by(cls.expense_date.desc()).all()
@classmethod
def get_uninvoiced_expenses(cls, project_id=None, client_id=None):
"""Get billable expenses that haven't been invoiced yet"""
query = cls.query.filter(
cls.status == 'approved',
cls.billable == True,
cls.invoiced == False
)
if project_id:
query = query.filter(cls.project_id == project_id)
if client_id:
query = query.filter(cls.client_id == client_id)
return query.order_by(cls.expense_date.desc()).all()
@classmethod
def get_expense_categories(cls):
"""Get list of available expense categories"""
return [
'travel',
'meals',
'accommodation',
'supplies',
'software',
'equipment',
'services',
'marketing',
'training',
'other'
]
@classmethod
def get_payment_methods(cls):
"""Get list of available payment methods"""
return [
'cash',
'credit_card',
'debit_card',
'bank_transfer',
'company_card',
'paypal',
'other'
]

108
app/models/permission.py Normal file
View File

@@ -0,0 +1,108 @@
"""Permission model for granular access control"""
from datetime import datetime
from app import db
class Permission(db.Model):
"""Permission model - represents a single permission in the system"""
__tablename__ = 'permissions'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False, index=True)
description = db.Column(db.String(255), nullable=True)
category = db.Column(db.String(50), nullable=False, index=True) # e.g., 'time_entries', 'projects', 'users', 'reports', 'system'
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
def __init__(self, name, description=None, category='general'):
self.name = name
self.description = description
self.category = category
def __repr__(self):
return f'<Permission {self.name}>'
def to_dict(self):
"""Convert permission to dictionary"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'category': self.category,
'created_at': self.created_at.isoformat() if self.created_at else None
}
# Association table for many-to-many relationship between roles and permissions
role_permissions = db.Table('role_permissions',
db.Column('role_id', db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
db.Column('permission_id', db.Integer, db.ForeignKey('permissions.id', ondelete='CASCADE'), primary_key=True),
db.Column('created_at', db.DateTime, default=datetime.utcnow, nullable=False)
)
class Role(db.Model):
"""Role model - bundles permissions together"""
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False, index=True)
description = db.Column(db.String(255), nullable=True)
is_system_role = db.Column(db.Boolean, default=False, nullable=False) # System roles cannot be deleted
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
permissions = db.relationship('Permission', secondary=role_permissions, lazy='joined',
backref=db.backref('roles', lazy='dynamic'))
def __init__(self, name, description=None, is_system_role=False):
self.name = name
self.description = description
self.is_system_role = is_system_role
def __repr__(self):
return f'<Role {self.name}>'
def has_permission(self, permission_name):
"""Check if role has a specific permission"""
return any(p.name == permission_name for p in self.permissions)
def add_permission(self, permission):
"""Add a permission to this role"""
if not self.has_permission(permission.name):
self.permissions.append(permission)
def remove_permission(self, permission):
"""Remove a permission from this role"""
if self.has_permission(permission.name):
self.permissions.remove(permission)
def get_permission_names(self):
"""Get list of permission names for this role"""
return [p.name for p in self.permissions]
def to_dict(self, include_permissions=False):
"""Convert role to dictionary"""
data = {
'id': self.id,
'name': self.name,
'description': self.description,
'is_system_role': self.is_system_role,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
if include_permissions:
data['permissions'] = [p.to_dict() for p in self.permissions]
data['permission_count'] = len(self.permissions)
return data
# Association table for many-to-many relationship between users and roles
user_roles = db.Table('user_roles',
db.Column('user_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
db.Column('role_id', db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
db.Column('assigned_at', db.DateTime, default=datetime.utcnow, nullable=False)
)

View File

@@ -23,6 +23,10 @@ class Project(db.Model):
budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # alert when exceeded
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)
# Archiving metadata
archived_at = db.Column(db.DateTime, nullable=True, index=True)
archived_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
archived_reason = db.Column(db.Text, nullable=True)
# Relationships
time_entries = db.relationship('TimeEntry', backref='project', lazy='dynamic', cascade='all, delete-orphan')
@@ -82,6 +86,19 @@ class Project(db.Model):
def is_active(self):
"""Check if project is active"""
return self.status == 'active'
@property
def is_archived(self):
"""Check if project is archived"""
return self.status == 'archived'
@property
def archived_by_user(self):
"""Get the user who archived this project"""
if self.archived_by:
from .user import User
return User.query.get(self.archived_by)
return None
@property
def code_display(self):
@@ -236,15 +253,26 @@ class Project(db.Model):
for _id, username, full_name, total_seconds in results
]
def archive(self):
"""Archive the project"""
def archive(self, user_id=None, reason=None):
"""Archive the project with metadata
Args:
user_id: ID of the user archiving the project
reason: Optional reason for archiving
"""
self.status = 'archived'
self.archived_at = datetime.utcnow()
self.archived_by = user_id
self.archived_reason = reason
self.updated_at = datetime.utcnow()
db.session.commit()
def unarchive(self):
"""Unarchive the project"""
"""Unarchive the project and clear archiving metadata"""
self.status = 'active'
self.archived_at = None
self.archived_by = None
self.archived_reason = None
self.updated_at = datetime.utcnow()
db.session.commit()
@@ -296,6 +324,11 @@ class Project(db.Model):
'total_costs': self.total_costs,
'total_billable_costs': self.total_billable_costs,
'total_project_value': self.total_project_value,
# Archiving metadata
'is_archived': self.is_archived,
'archived_at': self.archived_at.isoformat() if self.archived_at else None,
'archived_by': self.archived_by,
'archived_reason': self.archived_reason,
}
# Include favorite status if user is provided
if user:

View File

@@ -135,15 +135,20 @@ class TimeEntry(db.Model):
duration = self.end_time - self.start_time
raw_seconds = int(duration.total_seconds())
# Apply rounding
rounding_minutes = Config.ROUNDING_MINUTES
if rounding_minutes > 1:
# Round to nearest interval
minutes = raw_seconds / 60
rounded_minutes = round(minutes / rounding_minutes) * rounding_minutes
self.duration_seconds = int(rounded_minutes * 60)
# Apply per-user rounding if user preferences are set
if self.user and hasattr(self.user, 'time_rounding_enabled'):
from app.utils.time_rounding import apply_user_rounding
self.duration_seconds = apply_user_rounding(raw_seconds, self.user)
else:
self.duration_seconds = raw_seconds
# Fallback to global rounding setting for backward compatibility
rounding_minutes = Config.ROUNDING_MINUTES
if rounding_minutes > 1:
# Round to nearest interval
minutes = raw_seconds / 60
rounded_minutes = round(minutes / rounding_minutes) * rounding_minutes
self.duration_seconds = int(rounded_minutes * 60)
else:
self.duration_seconds = raw_seconds
def stop_timer(self, end_time=None):
"""Stop an active timer"""

View File

@@ -37,10 +37,16 @@ class User(UserMixin, db.Model):
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'
# 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'))
def __init__(self, username, role='user', email=None, full_name=None):
self.username = username.lower().strip()
@@ -54,7 +60,11 @@ class User(UserMixin, db.Model):
@property
def is_admin(self):
"""Check if user is an admin"""
return self.role == '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):
@@ -168,3 +178,47 @@ class User(UserMixin, db.Model):
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]

View File

@@ -0,0 +1,206 @@
from datetime import datetime, timedelta
from app import db
from sqlalchemy import func
def local_now():
"""Get current time in local timezone"""
import os
import pytz
# Get timezone from environment variable, default to Europe/Rome
timezone_name = os.getenv('TZ', 'Europe/Rome')
tz = pytz.timezone(timezone_name)
now = datetime.now(tz)
return now.replace(tzinfo=None)
class WeeklyTimeGoal(db.Model):
"""Weekly time goal model for tracking user's weekly hour targets"""
__tablename__ = 'weekly_time_goals'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
target_hours = db.Column(db.Float, nullable=False) # Target hours for the week
week_start_date = db.Column(db.Date, nullable=False, index=True) # Monday of the week
week_end_date = db.Column(db.Date, nullable=False) # Sunday of the week
status = db.Column(db.String(20), default='active', nullable=False) # 'active', 'completed', 'failed', 'cancelled'
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)
# Relationships
user = db.relationship('User', backref=db.backref('weekly_goals', lazy='dynamic', cascade='all, delete-orphan'))
def __init__(self, user_id, target_hours, week_start_date=None, notes=None, **kwargs):
"""Initialize a WeeklyTimeGoal instance.
Args:
user_id: ID of the user who created this goal
target_hours: Target hours for the week
week_start_date: Start date of the week (Monday). If None, uses current week.
notes: Optional notes about the goal
**kwargs: Additional keyword arguments (for SQLAlchemy compatibility)
"""
self.user_id = user_id
self.target_hours = target_hours
# If no week_start_date provided, calculate the current week's Monday
if week_start_date is None:
from app.models.user import User
user = User.query.get(user_id)
week_start_day = user.week_start_day if user else 1 # Default to Monday (user convention: 0=Sunday, 1=Monday)
today = local_now().date()
# Convert user convention (0=Sunday, 1=Monday) to Python weekday (0=Monday, 6=Sunday)
python_week_start_day = (week_start_day - 1) % 7
days_since_week_start = (today.weekday() - python_week_start_day) % 7
week_start_date = today - timedelta(days=days_since_week_start)
self.week_start_date = week_start_date
self.week_end_date = week_start_date + timedelta(days=6)
self.notes = notes
# Allow status override from kwargs
if 'status' in kwargs:
self.status = kwargs['status']
def __repr__(self):
return f'<WeeklyTimeGoal user_id={self.user_id} week={self.week_start_date} target={self.target_hours}h>'
@property
def actual_hours(self):
"""Calculate actual hours worked during this week"""
from app.models.time_entry import TimeEntry
# Query time entries for this user within the week range
total_seconds = db.session.query(
func.sum(TimeEntry.duration_seconds)
).filter(
TimeEntry.user_id == self.user_id,
TimeEntry.end_time.isnot(None),
func.date(TimeEntry.start_time) >= self.week_start_date,
func.date(TimeEntry.start_time) <= self.week_end_date
).scalar() or 0
return round(total_seconds / 3600, 2)
@property
def progress_percentage(self):
"""Calculate progress as a percentage"""
if self.target_hours <= 0:
return 0
percentage = (self.actual_hours / self.target_hours) * 100
return min(round(percentage, 1), 100) # Cap at 100%
@property
def remaining_hours(self):
"""Calculate remaining hours to reach the goal"""
remaining = self.target_hours - self.actual_hours
return max(round(remaining, 2), 0)
@property
def is_completed(self):
"""Check if the goal has been met"""
return self.actual_hours >= self.target_hours
@property
def is_overdue(self):
"""Check if the week has passed and goal is not completed"""
today = local_now().date()
return today > self.week_end_date and not self.is_completed
@property
def days_remaining(self):
"""Calculate days remaining in the week"""
today = local_now().date()
if today > self.week_end_date:
return 0
return (self.week_end_date - today).days + 1
@property
def average_hours_per_day(self):
"""Calculate average hours needed per day to reach goal"""
if self.days_remaining <= 0:
return 0
return round(self.remaining_hours / self.days_remaining, 2)
@property
def week_label(self):
"""Get a human-readable label for the week"""
return f"{self.week_start_date.strftime('%b %d')} - {self.week_end_date.strftime('%b %d, %Y')}"
def update_status(self):
"""Update the goal status based on current date and progress"""
today = local_now().date()
if self.status == 'cancelled':
return # Don't auto-update cancelled goals
if today > self.week_end_date:
# Week has ended
if self.is_completed:
self.status = 'completed'
else:
self.status = 'failed'
elif self.is_completed and self.status == 'active':
self.status = 'completed'
db.session.commit()
def to_dict(self):
"""Convert goal to dictionary for API responses"""
return {
'id': self.id,
'user_id': self.user_id,
'target_hours': self.target_hours,
'actual_hours': self.actual_hours,
'week_start_date': self.week_start_date.isoformat(),
'week_end_date': self.week_end_date.isoformat(),
'week_label': self.week_label,
'status': self.status,
'notes': self.notes,
'progress_percentage': self.progress_percentage,
'remaining_hours': self.remaining_hours,
'is_completed': self.is_completed,
'is_overdue': self.is_overdue,
'days_remaining': self.days_remaining,
'average_hours_per_day': self.average_hours_per_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,
}
@staticmethod
def get_current_week_goal(user_id):
"""Get the goal for the current week for a specific user"""
from app.models.user import User
user = User.query.get(user_id)
week_start_day = user.week_start_day if user else 1 # User convention: 0=Sunday, 1=Monday
today = local_now().date()
# Convert user convention (0=Sunday, 1=Monday) to Python weekday (0=Monday, 6=Sunday)
python_week_start_day = (week_start_day - 1) % 7
days_since_week_start = (today.weekday() - python_week_start_day) % 7
week_start = today - timedelta(days=days_since_week_start)
week_end = week_start + timedelta(days=6)
return WeeklyTimeGoal.query.filter(
WeeklyTimeGoal.user_id == user_id,
WeeklyTimeGoal.week_start_date == week_start,
WeeklyTimeGoal.status != 'cancelled'
).first()
@staticmethod
def get_or_create_current_week(user_id, default_target_hours=40):
"""Get or create a goal for the current week"""
goal = WeeklyTimeGoal.get_current_week_goal(user_id)
if not goal:
goal = WeeklyTimeGoal(
user_id=user_id,
target_hours=default_target_hours
)
db.session.add(goal)
db.session.commit()
return goal

View File

@@ -13,6 +13,7 @@ from app.utils.db import safe_commit
from app.utils.backup import create_backup, restore_backup
from app.utils.installation import get_installation_config
from app.utils.telemetry import get_telemetry_fingerprint, is_telemetry_enabled
from app.utils.permissions import admin_or_permission_required
import threading
import time
@@ -26,7 +27,11 @@ RESTORE_PROGRESS = {}
ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
def admin_required(f):
"""Decorator to require admin access"""
"""Decorator to require admin access
DEPRECATED: Use @admin_or_permission_required() with specific permissions instead.
This decorator is kept for backward compatibility.
"""
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -122,7 +127,7 @@ def admin_dashboard_alias():
@admin_bp.route('/admin/users')
@login_required
@admin_required
@admin_or_permission_required('view_users')
def list_users():
"""List all users"""
users = User.query.order_by(User.username).all()
@@ -139,7 +144,7 @@ def list_users():
@admin_bp.route('/admin/users/create', methods=['GET', 'POST'])
@login_required
@admin_required
@admin_or_permission_required('create_users')
def create_user():
"""Create a new user"""
if request.method == 'POST':
@@ -169,7 +174,7 @@ def create_user():
@admin_bp.route('/admin/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
@admin_or_permission_required('edit_users')
def edit_user(user_id):
"""Edit an existing user"""
user = User.query.get_or_404(user_id)
@@ -204,7 +209,7 @@ def edit_user(user_id):
@admin_bp.route('/admin/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
@admin_or_permission_required('delete_users')
def delete_user(user_id):
"""Delete a user"""
user = User.query.get_or_404(user_id)
@@ -232,7 +237,7 @@ def delete_user(user_id):
@admin_bp.route('/admin/telemetry')
@login_required
@admin_required
@admin_or_permission_required('manage_telemetry')
def telemetry_dashboard():
"""Telemetry and analytics dashboard"""
installation_config = get_installation_config()
@@ -273,7 +278,7 @@ def telemetry_dashboard():
@admin_bp.route('/admin/telemetry/toggle', methods=['POST'])
@login_required
@admin_required
@admin_or_permission_required('manage_telemetry')
def toggle_telemetry():
"""Toggle telemetry on/off"""
installation_config = get_installation_config()
@@ -296,7 +301,7 @@ def toggle_telemetry():
@admin_bp.route('/admin/settings', methods=['GET', 'POST'])
@login_required
@admin_required
@admin_or_permission_required('manage_settings')
def settings():
"""Manage system settings"""
settings_obj = Settings.get_settings()
@@ -352,7 +357,7 @@ def settings():
@admin_bp.route('/admin/pdf-layout', methods=['GET', 'POST'])
@limiter.limit("30 per minute", methods=["POST"]) # editor saves
@login_required
@admin_required
@admin_or_permission_required('manage_settings')
def pdf_layout():
"""Edit PDF invoice layout template (HTML and CSS)."""
settings_obj = Settings.get_settings()
@@ -394,7 +399,7 @@ def pdf_layout():
@admin_bp.route('/admin/pdf-layout/reset', methods=['POST'])
@limiter.limit("10 per minute")
@login_required
@admin_required
@admin_or_permission_required('manage_settings')
def pdf_layout_reset():
"""Reset PDF layout to defaults (clear custom templates)."""
settings_obj = Settings.get_settings()
@@ -409,7 +414,7 @@ def pdf_layout_reset():
@admin_bp.route('/admin/pdf-layout/default', methods=['GET'])
@login_required
@admin_required
@admin_or_permission_required('manage_settings')
def pdf_layout_default():
"""Return default HTML and CSS template sources for the PDF layout editor."""
try:
@@ -439,7 +444,7 @@ def pdf_layout_default():
@admin_bp.route('/admin/pdf-layout/preview', methods=['POST'])
@limiter.limit("60 per minute")
@login_required
@admin_required
@admin_or_permission_required('manage_settings')
def pdf_layout_preview():
"""Render a live preview of the provided HTML/CSS using an invoice context."""
html = request.form.get('html', '')
@@ -574,7 +579,7 @@ def pdf_layout_preview():
@admin_bp.route('/admin/upload-logo', methods=['POST'])
@limiter.limit("10 per minute")
@login_required
@admin_required
@admin_or_permission_required('manage_settings')
def upload_logo():
"""Upload company logo"""
if 'logo' not in request.files:
@@ -632,7 +637,7 @@ def upload_logo():
@admin_bp.route('/admin/remove-logo', methods=['POST'])
@login_required
@admin_required
@admin_or_permission_required('manage_settings')
def remove_logo():
"""Remove company logo"""
settings_obj = Settings.get_settings()
@@ -669,7 +674,7 @@ def serve_uploaded_logo(filename):
@admin_bp.route('/admin/backup', methods=['GET'])
@login_required
@admin_required
@admin_or_permission_required('manage_backups')
def backup():
"""Create manual backup and return the archive for download."""
try:
@@ -686,7 +691,7 @@ def backup():
@admin_bp.route('/admin/restore', methods=['GET', 'POST'])
@limiter.limit("3 per minute", methods=["POST"]) # heavy operation
@login_required
@admin_required
@admin_or_permission_required('manage_backups')
def restore():
"""Restore from an uploaded backup archive."""
if request.method == 'POST':
@@ -744,7 +749,7 @@ def restore():
@admin_bp.route('/admin/system')
@login_required
@admin_required
@admin_or_permission_required('view_system_info')
def system_info():
"""Show system information"""
# Get system statistics
@@ -781,7 +786,7 @@ def system_info():
@admin_bp.route('/admin/oidc/debug')
@login_required
@admin_required
@admin_or_permission_required('manage_oidc')
def oidc_debug():
"""OIDC Configuration Debug Dashboard"""
from app.config import Config
@@ -845,7 +850,7 @@ def oidc_debug():
@admin_bp.route('/admin/oidc/test')
@limiter.limit("10 per minute")
@login_required
@admin_required
@admin_or_permission_required('manage_oidc')
def oidc_test():
"""Test OIDC configuration by fetching discovery document"""
from app.config import Config
@@ -939,7 +944,7 @@ def oidc_test():
@admin_bp.route('/admin/oidc/user/<int:user_id>')
@login_required
@admin_required
@admin_or_permission_required('view_users')
def oidc_user_detail(user_id):
"""View OIDC details for a specific user"""
user = User.query.get_or_404(user_id)

260
app/routes/client_notes.py Normal file
View File

@@ -0,0 +1,260 @@
from flask import Blueprint, request, redirect, url_for, flash, jsonify, render_template
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import ClientNote, Client
from app.utils.db import safe_commit
client_notes_bp = Blueprint('client_notes', __name__)
@client_notes_bp.route('/clients/<int:client_id>/notes/create', methods=['POST'])
@login_required
def create_note(client_id):
"""Create a new note for a client"""
try:
content = request.form.get('content', '').strip()
is_important = request.form.get('is_important', 'false').lower() == 'true'
# Validation
if not content:
flash(_('Note content cannot be empty'), 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
# Verify client exists
client = Client.query.get_or_404(client_id)
# Create the note
note = ClientNote(
content=content,
user_id=current_user.id,
client_id=client_id,
is_important=is_important
)
db.session.add(note)
if safe_commit('create_client_note', {'client_id': client_id}):
# Log note creation
log_event("client_note.created",
user_id=current_user.id,
client_note_id=note.id,
client_id=client_id)
track_event(current_user.id, "client_note.created", {
"note_id": note.id,
"client_id": client_id
})
flash(_('Note added successfully'), 'success')
else:
flash(_('Error adding note'), 'error')
except ValueError as e:
flash(_('Error adding note: %(error)s', error=str(e)), 'error')
except Exception as e:
flash(_('Error adding note: %(error)s', error=str(e)), 'error')
# Redirect back to the client page
return redirect(url_for('clients.view_client', client_id=client_id))
@client_notes_bp.route('/clients/<int:client_id>/notes/<int:note_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_note(client_id, note_id):
"""Edit an existing client note"""
note = ClientNote.query.get_or_404(note_id)
# Verify note belongs to this client
if note.client_id != client_id:
flash(_('Note does not belong to this client'), 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
# Check permissions
if not note.can_edit(current_user):
flash(_('You do not have permission to edit this note'), 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
if request.method == 'POST':
try:
content = request.form.get('content', '').strip()
is_important = request.form.get('is_important', 'false').lower() == 'true'
if not content:
flash(_('Note content cannot be empty'), 'error')
return render_template('client_notes/edit.html', note=note, client_id=client_id)
note.edit_content(content, current_user, is_important=is_important)
if not safe_commit('edit_client_note', {'note_id': note_id}):
flash(_('Error updating note'), 'error')
return render_template('client_notes/edit.html', note=note, client_id=client_id)
# Log note update
log_event("client_note.updated", user_id=current_user.id, client_note_id=note.id)
track_event(current_user.id, "client_note.updated", {"note_id": note.id})
flash(_('Note updated successfully'), 'success')
return redirect(url_for('clients.view_client', client_id=client_id))
except ValueError as e:
flash(_('Error updating note: %(error)s', error=str(e)), 'error')
except Exception as e:
flash(_('Error updating note: %(error)s', error=str(e)), 'error')
return render_template('client_notes/edit.html', note=note, client_id=client_id)
@client_notes_bp.route('/clients/<int:client_id>/notes/<int:note_id>/delete', methods=['POST'])
@login_required
def delete_note(client_id, note_id):
"""Delete a client note"""
note = ClientNote.query.get_or_404(note_id)
# Verify note belongs to this client
if note.client_id != client_id:
flash(_('Note does not belong to this client'), 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
# Check permissions
if not note.can_delete(current_user):
flash(_('You do not have permission to delete this note'), 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
try:
note_id_for_log = note.id
db.session.delete(note)
if not safe_commit('delete_client_note', {'note_id': note_id}):
flash(_('Error deleting note'), 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
# Log note deletion
log_event("client_note.deleted", user_id=current_user.id, client_note_id=note_id_for_log)
track_event(current_user.id, "client_note.deleted", {"note_id": note_id_for_log})
flash(_('Note deleted successfully'), 'success')
except Exception as e:
flash(_('Error deleting note: %(error)s', error=str(e)), 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
@client_notes_bp.route('/clients/<int:client_id>/notes/<int:note_id>/toggle-important', methods=['POST'])
@login_required
def toggle_important(client_id, note_id):
"""Toggle the important flag on a client note"""
note = ClientNote.query.get_or_404(note_id)
# Verify note belongs to this client
if note.client_id != client_id:
return jsonify({'error': 'Note does not belong to this client'}), 400
# Check permissions
if not note.can_edit(current_user):
return jsonify({'error': 'Permission denied'}), 403
try:
note.is_important = not note.is_important
if not safe_commit('toggle_important_note', {'note_id': note_id}):
return jsonify({'error': 'Error updating note'}), 500
# Log note update
log_event("client_note.importance_toggled",
user_id=current_user.id,
client_note_id=note.id,
is_important=note.is_important)
track_event(current_user.id, "client_note.importance_toggled", {
"note_id": note.id,
"is_important": note.is_important
})
return jsonify({
'success': True,
'is_important': note.is_important
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@client_notes_bp.route('/api/clients/<int:client_id>/notes')
@login_required
def list_notes(client_id):
"""API endpoint to get notes for a client"""
order_by_important = request.args.get('order_by_important', 'false').lower() == 'true'
try:
# Verify client exists
client = Client.query.get_or_404(client_id)
notes = ClientNote.get_client_notes(client_id, order_by_important)
return jsonify({
'success': True,
'notes': [note.to_dict() for note in notes]
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@client_notes_bp.route('/api/client-notes/<int:note_id>')
@login_required
def get_note(note_id):
"""API endpoint to get a single client note"""
try:
note = ClientNote.query.get_or_404(note_id)
return jsonify({
'success': True,
'note': note.to_dict()
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@client_notes_bp.route('/api/client-notes/important')
@login_required
def get_important_notes():
"""API endpoint to get all important client notes"""
client_id = request.args.get('client_id', type=int)
try:
notes = ClientNote.get_important_notes(client_id)
return jsonify({
'success': True,
'notes': [note.to_dict() for note in notes]
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@client_notes_bp.route('/api/client-notes/recent')
@login_required
def get_recent_notes():
"""API endpoint to get recent client notes"""
limit = request.args.get('limit', 10, type=int)
try:
notes = ClientNote.get_recent_notes(limit)
return jsonify({
'success': True,
'notes': [note.to_dict() for note in notes]
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@client_notes_bp.route('/api/client-notes/user/<int:user_id>')
@login_required
def get_user_notes(user_id):
"""API endpoint to get notes by a specific user"""
limit = request.args.get('limit', type=int)
# Only allow users to see their own notes unless they're admin
if not current_user.is_admin and current_user.id != user_id:
return jsonify({'error': 'Permission denied'}), 403
try:
notes = ClientNote.get_user_notes(user_id, limit)
return jsonify({
'success': True,
'notes': [note.to_dict() for note in notes]
})
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -7,6 +7,7 @@ from app.models import Client, Project
from datetime import datetime
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required
clients_bp = Blueprint('clients', __name__)
@@ -63,13 +64,14 @@ def create_client():
except Exception:
wants_json = False
if not current_user.is_admin:
# Check permissions
if not current_user.is_admin and not current_user.has_permission('create_clients'):
if wants_json:
return jsonify({
'error': 'forbidden',
'message': _('Only administrators can create clients')
'message': _('You do not have permission to create clients')
}), 403
flash(_('Only administrators can create clients'), 'error')
flash(_('You do not have permission to create clients'), 'error')
return redirect(url_for('clients.list_clients'))
if request.method == 'POST':
@@ -174,12 +176,13 @@ def view_client(client_id):
@login_required
def edit_client(client_id):
"""Edit client details"""
if not current_user.is_admin:
flash('Only administrators can edit clients', 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
client = Client.query.get_or_404(client_id)
# Check permissions
if not current_user.is_admin and not current_user.has_permission('edit_clients'):
flash('You do not have permission to edit clients', 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
@@ -234,12 +237,13 @@ def edit_client(client_id):
@login_required
def archive_client(client_id):
"""Archive a client"""
if not current_user.is_admin:
flash('Only administrators can archive clients', 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
client = Client.query.get_or_404(client_id)
# Check permissions
if not current_user.is_admin and not current_user.has_permission('edit_clients'):
flash('You do not have permission to archive clients', 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
if client.status == 'inactive':
flash('Client is already inactive', 'info')
else:
@@ -254,12 +258,13 @@ def archive_client(client_id):
@login_required
def activate_client(client_id):
"""Activate a client"""
if not current_user.is_admin:
flash('Only administrators can activate clients', 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
client = Client.query.get_or_404(client_id)
# Check permissions
if not current_user.is_admin and not current_user.has_permission('edit_clients'):
flash('You do not have permission to activate clients', 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
if client.status == 'active':
flash('Client is already active', 'info')
else:
@@ -272,12 +277,13 @@ def activate_client(client_id):
@login_required
def delete_client(client_id):
"""Delete a client (only if no projects exist)"""
if not current_user.is_admin:
flash('Only administrators can delete clients', 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
client = Client.query.get_or_404(client_id)
# Check permissions
if not current_user.is_admin and not current_user.has_permission('delete_clients'):
flash('You do not have permission to delete clients', 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
# Check if client has projects
if client.projects.count() > 0:
flash('Cannot delete client with existing projects', 'error')
@@ -301,8 +307,9 @@ def delete_client(client_id):
@login_required
def bulk_delete_clients():
"""Delete multiple clients at once"""
if not current_user.is_admin:
flash('Only administrators can delete clients', 'error')
# Check permissions
if not current_user.is_admin and not current_user.has_permission('delete_clients'):
flash('You do not have permission to delete clients', 'error')
return redirect(url_for('clients.list_clients'))
client_ids = request.form.getlist('client_ids[]')
@@ -366,8 +373,9 @@ def bulk_delete_clients():
@login_required
def bulk_status_change():
"""Change status for multiple clients at once"""
if not current_user.is_admin:
flash('Only administrators can change client status', 'error')
# Check permissions
if not current_user.is_admin and not current_user.has_permission('edit_clients'):
flash('You do not have permission to change client status', 'error')
return redirect(url_for('clients.list_clients'))
client_ids = request.form.getlist('client_ids[]')

885
app/routes/expenses.py Normal file
View File

@@ -0,0 +1,885 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import Expense, Project, Client, User
from datetime import datetime, date, timedelta
from decimal import Decimal
from app.utils.db import safe_commit
import csv
import io
import os
from werkzeug.utils import secure_filename
expenses_bp = Blueprint('expenses', __name__)
# File upload configuration
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
UPLOAD_FOLDER = 'uploads/receipts'
def allowed_file(filename):
"""Check if file extension is allowed"""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@expenses_bp.route('/expenses')
@login_required
def list_expenses():
"""List all expenses with filters"""
# Track page view
from app import track_page_view
track_page_view("expenses_list")
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 25, type=int)
# Filter parameters
status = request.args.get('status', '').strip()
category = request.args.get('category', '').strip()
project_id = request.args.get('project_id', type=int)
client_id = request.args.get('client_id', type=int)
user_id = request.args.get('user_id', type=int)
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
search = request.args.get('search', '').strip()
billable = request.args.get('billable', '').strip()
reimbursable = request.args.get('reimbursable', '').strip()
# Build query
query = Expense.query
# Non-admin users can only see their own expenses or expenses they approved
if not current_user.is_admin:
query = query.filter(
db.or_(
Expense.user_id == current_user.id,
Expense.approved_by == current_user.id
)
)
# Apply filters
if status:
query = query.filter(Expense.status == status)
if category:
query = query.filter(Expense.category == category)
if project_id:
query = query.filter(Expense.project_id == project_id)
if client_id:
query = query.filter(Expense.client_id == client_id)
if user_id and current_user.is_admin:
query = query.filter(Expense.user_id == user_id)
if start_date:
try:
start = datetime.strptime(start_date, '%Y-%m-%d').date()
query = query.filter(Expense.expense_date >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d').date()
query = query.filter(Expense.expense_date <= end)
except ValueError:
pass
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Expense.title.ilike(like),
Expense.description.ilike(like),
Expense.vendor.ilike(like),
Expense.notes.ilike(like)
)
)
if billable == 'true':
query = query.filter(Expense.billable == True)
elif billable == 'false':
query = query.filter(Expense.billable == False)
if reimbursable == 'true':
query = query.filter(Expense.reimbursable == True)
elif reimbursable == 'false':
query = query.filter(Expense.reimbursable == False)
# Paginate
expenses_pagination = query.order_by(Expense.expense_date.desc()).paginate(
page=page,
per_page=per_page,
error_out=False
)
# Get filter options
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
categories = Expense.get_expense_categories()
# Get users for admin filter
users = []
if current_user.is_admin:
users = User.query.filter_by(is_active=True).order_by(User.username).all()
# Calculate totals for current filters (without pagination)
total_amount = 0
total_count = query.count()
if total_count > 0:
total_query = db.session.query(
db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0))
)
# Apply same filters
if status:
total_query = total_query.filter(Expense.status == status)
if category:
total_query = total_query.filter(Expense.category == category)
if project_id:
total_query = total_query.filter(Expense.project_id == project_id)
if client_id:
total_query = total_query.filter(Expense.client_id == client_id)
if user_id and current_user.is_admin:
total_query = total_query.filter(Expense.user_id == user_id)
if start_date:
try:
start = datetime.strptime(start_date, '%Y-%m-%d').date()
total_query = total_query.filter(Expense.expense_date >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d').date()
total_query = total_query.filter(Expense.expense_date <= end)
except ValueError:
pass
# Non-admin users restriction
if not current_user.is_admin:
total_query = total_query.filter(
db.or_(
Expense.user_id == current_user.id,
Expense.approved_by == current_user.id
)
)
total_amount = total_query.scalar() or 0
return render_template(
'expenses/list.html',
expenses=expenses_pagination.items,
pagination=expenses_pagination,
projects=projects,
clients=clients,
categories=categories,
users=users,
total_amount=float(total_amount),
total_count=total_count,
# Pass back filter values
status=status,
category=category,
project_id=project_id,
client_id=client_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
search=search,
billable=billable,
reimbursable=reimbursable
)
@expenses_bp.route('/expenses/create', methods=['GET', 'POST'])
@login_required
def create_expense():
"""Create a new expense"""
if request.method == 'GET':
# Get data for form
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
categories = Expense.get_expense_categories()
payment_methods = Expense.get_payment_methods()
return render_template(
'expenses/form.html',
expense=None,
projects=projects,
clients=clients,
categories=categories,
payment_methods=payment_methods
)
try:
# Get form data
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', '').strip()
amount = request.form.get('amount', '0').strip()
currency_code = request.form.get('currency_code', 'EUR').strip()
tax_amount = request.form.get('tax_amount', '0').strip()
expense_date = request.form.get('expense_date', '').strip()
# Validate required fields
if not title:
flash(_('Title is required'), 'error')
return redirect(url_for('expenses.create_expense'))
if not category:
flash(_('Category is required'), 'error')
return redirect(url_for('expenses.create_expense'))
if not amount:
flash(_('Amount is required'), 'error')
return redirect(url_for('expenses.create_expense'))
if not expense_date:
flash(_('Expense date is required'), 'error')
return redirect(url_for('expenses.create_expense'))
# Parse date
try:
expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return redirect(url_for('expenses.create_expense'))
# Parse amounts
try:
amount_decimal = Decimal(amount)
tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0')
except (ValueError, Decimal.InvalidOperation):
flash(_('Invalid amount format'), 'error')
return redirect(url_for('expenses.create_expense'))
# Optional fields
project_id = request.form.get('project_id', type=int)
client_id = request.form.get('client_id', type=int)
payment_method = request.form.get('payment_method', '').strip()
payment_date = request.form.get('payment_date', '').strip()
vendor = request.form.get('vendor', '').strip()
receipt_number = request.form.get('receipt_number', '').strip()
notes = request.form.get('notes', '').strip()
tags = request.form.get('tags', '').strip()
billable = request.form.get('billable') == 'on'
reimbursable = request.form.get('reimbursable') == 'on'
# Parse payment date if provided
payment_date_obj = None
if payment_date:
try:
payment_date_obj = datetime.strptime(payment_date, '%Y-%m-%d').date()
except ValueError:
pass
# Handle file upload
receipt_path = None
if 'receipt_file' in request.files:
file = request.files['receipt_file']
if file and file.filename and allowed_file(file.filename):
filename = secure_filename(file.filename)
# Add timestamp to filename to avoid collisions
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{timestamp}_{filename}"
# Ensure upload directory exists
upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, filename)
file.save(file_path)
receipt_path = os.path.join(UPLOAD_FOLDER, filename)
# Create expense
expense = Expense(
user_id=current_user.id,
title=title,
category=category,
amount=amount_decimal,
expense_date=expense_date_obj,
description=description,
currency_code=currency_code,
tax_amount=tax_amount_decimal,
project_id=project_id,
client_id=client_id,
payment_method=payment_method,
payment_date=payment_date_obj,
vendor=vendor,
receipt_number=receipt_number,
receipt_path=receipt_path,
notes=notes,
tags=tags,
billable=billable,
reimbursable=reimbursable
)
db.session.add(expense)
if safe_commit(db):
flash(_('Expense created successfully'), 'success')
log_event('expense_created', user_id=current_user.id, expense_id=expense.id)
track_event(current_user.id, 'expense.created', {
'expense_id': expense.id,
'category': category,
'amount': float(amount_decimal),
'billable': billable,
'reimbursable': reimbursable
})
return redirect(url_for('expenses.view_expense', expense_id=expense.id))
else:
flash(_('Error creating expense'), 'error')
return redirect(url_for('expenses.create_expense'))
except Exception as e:
current_app.logger.error(f"Error creating expense: {e}")
flash(_('Error creating expense'), 'error')
return redirect(url_for('expenses.create_expense'))
@expenses_bp.route('/expenses/<int:expense_id>')
@login_required
def view_expense(expense_id):
"""View expense details"""
expense = Expense.query.get_or_404(expense_id)
# Check permission
if not current_user.is_admin and expense.user_id != current_user.id and expense.approved_by != current_user.id:
flash(_('You do not have permission to view this expense'), 'error')
return redirect(url_for('expenses.list_expenses'))
# Track page view
from app import track_page_view
track_page_view("expense_detail", properties={'expense_id': expense_id})
return render_template('expenses/view.html', expense=expense)
@expenses_bp.route('/expenses/<int:expense_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_expense(expense_id):
"""Edit an existing expense"""
expense = Expense.query.get_or_404(expense_id)
# Check permission - only owner can edit (unless admin)
if not current_user.is_admin and expense.user_id != current_user.id:
flash(_('You do not have permission to edit this expense'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
# Cannot edit approved or reimbursed expenses without admin privileges
if not current_user.is_admin and expense.status in ['approved', 'reimbursed']:
flash(_('Cannot edit approved or reimbursed expenses'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
if request.method == 'GET':
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
categories = Expense.get_expense_categories()
payment_methods = Expense.get_payment_methods()
return render_template(
'expenses/form.html',
expense=expense,
projects=projects,
clients=clients,
categories=categories,
payment_methods=payment_methods
)
try:
# Get form data
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', '').strip()
amount = request.form.get('amount', '0').strip()
currency_code = request.form.get('currency_code', 'EUR').strip()
tax_amount = request.form.get('tax_amount', '0').strip()
expense_date = request.form.get('expense_date', '').strip()
# Validate required fields
if not title or not category or not amount or not expense_date:
flash(_('Please fill in all required fields'), 'error')
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
# Parse date
try:
expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
# Parse amounts
try:
amount_decimal = Decimal(amount)
tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0')
except (ValueError, Decimal.InvalidOperation):
flash(_('Invalid amount format'), 'error')
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
# Update expense fields
expense.title = title
expense.description = description
expense.category = category
expense.amount = amount_decimal
expense.currency_code = currency_code
expense.tax_amount = tax_amount_decimal
expense.expense_date = expense_date_obj
# Optional fields
expense.project_id = request.form.get('project_id', type=int)
expense.client_id = request.form.get('client_id', type=int)
expense.payment_method = request.form.get('payment_method', '').strip()
expense.vendor = request.form.get('vendor', '').strip()
expense.receipt_number = request.form.get('receipt_number', '').strip()
expense.notes = request.form.get('notes', '').strip()
expense.tags = request.form.get('tags', '').strip()
expense.billable = request.form.get('billable') == 'on'
expense.reimbursable = request.form.get('reimbursable') == 'on'
# Parse payment date if provided
payment_date = request.form.get('payment_date', '').strip()
if payment_date:
try:
expense.payment_date = datetime.strptime(payment_date, '%Y-%m-%d').date()
except ValueError:
expense.payment_date = None
else:
expense.payment_date = None
# Handle file upload
if 'receipt_file' in request.files:
file = request.files['receipt_file']
if file and file.filename and allowed_file(file.filename):
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{timestamp}_{filename}"
upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, filename)
file.save(file_path)
# Delete old receipt if exists
if expense.receipt_path:
old_file_path = os.path.join(current_app.root_path, '..', expense.receipt_path)
if os.path.exists(old_file_path):
try:
os.remove(old_file_path)
except Exception:
pass
expense.receipt_path = os.path.join(UPLOAD_FOLDER, filename)
expense.updated_at = datetime.utcnow()
if safe_commit(db):
flash(_('Expense updated successfully'), 'success')
log_event('expense_updated', user_id=current_user.id, expense_id=expense.id)
track_event(current_user.id, 'expense.updated', {'expense_id': expense.id})
return redirect(url_for('expenses.view_expense', expense_id=expense.id))
else:
flash(_('Error updating expense'), 'error')
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
except Exception as e:
current_app.logger.error(f"Error updating expense: {e}")
flash(_('Error updating expense'), 'error')
return redirect(url_for('expenses.edit_expense', expense_id=expense_id))
@expenses_bp.route('/expenses/<int:expense_id>/delete', methods=['POST'])
@login_required
def delete_expense(expense_id):
"""Delete an expense"""
expense = Expense.query.get_or_404(expense_id)
# Check permission
if not current_user.is_admin and expense.user_id != current_user.id:
flash(_('You do not have permission to delete this expense'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
# Cannot delete approved or invoiced expenses without admin privileges
if not current_user.is_admin and (expense.status == 'approved' or expense.invoiced):
flash(_('Cannot delete approved or invoiced expenses'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
try:
# Delete receipt file if exists
if expense.receipt_path:
file_path = os.path.join(current_app.root_path, '..', expense.receipt_path)
if os.path.exists(file_path):
try:
os.remove(file_path)
except Exception:
pass
db.session.delete(expense)
if safe_commit(db):
flash(_('Expense deleted successfully'), 'success')
log_event('expense_deleted', user_id=current_user.id, expense_id=expense_id)
track_event(current_user.id, 'expense.deleted', {'expense_id': expense_id})
else:
flash(_('Error deleting expense'), 'error')
except Exception as e:
current_app.logger.error(f"Error deleting expense: {e}")
flash(_('Error deleting expense'), 'error')
return redirect(url_for('expenses.list_expenses'))
@expenses_bp.route('/expenses/<int:expense_id>/approve', methods=['POST'])
@login_required
def approve_expense(expense_id):
"""Approve an expense"""
if not current_user.is_admin:
flash(_('Only administrators can approve expenses'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
expense = Expense.query.get_or_404(expense_id)
if expense.status != 'pending':
flash(_('Only pending expenses can be approved'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
try:
notes = request.form.get('approval_notes', '').strip()
expense.approve(current_user.id, notes)
if safe_commit(db):
flash(_('Expense approved successfully'), 'success')
log_event('expense_approved', user_id=current_user.id, expense_id=expense_id)
track_event(current_user.id, 'expense.approved', {'expense_id': expense_id})
else:
flash(_('Error approving expense'), 'error')
except Exception as e:
current_app.logger.error(f"Error approving expense: {e}")
flash(_('Error approving expense'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
@expenses_bp.route('/expenses/<int:expense_id>/reject', methods=['POST'])
@login_required
def reject_expense(expense_id):
"""Reject an expense"""
if not current_user.is_admin:
flash(_('Only administrators can reject expenses'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
expense = Expense.query.get_or_404(expense_id)
if expense.status != 'pending':
flash(_('Only pending expenses can be rejected'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
try:
reason = request.form.get('rejection_reason', '').strip()
if not reason:
flash(_('Rejection reason is required'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
expense.reject(current_user.id, reason)
if safe_commit(db):
flash(_('Expense rejected'), 'success')
log_event('expense_rejected', user_id=current_user.id, expense_id=expense_id)
track_event(current_user.id, 'expense.rejected', {'expense_id': expense_id})
else:
flash(_('Error rejecting expense'), 'error')
except Exception as e:
current_app.logger.error(f"Error rejecting expense: {e}")
flash(_('Error rejecting expense'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
@expenses_bp.route('/expenses/<int:expense_id>/reimburse', methods=['POST'])
@login_required
def mark_reimbursed(expense_id):
"""Mark an expense as reimbursed"""
if not current_user.is_admin:
flash(_('Only administrators can mark expenses as reimbursed'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
expense = Expense.query.get_or_404(expense_id)
if expense.status != 'approved':
flash(_('Only approved expenses can be marked as reimbursed'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
if not expense.reimbursable:
flash(_('This expense is not marked as reimbursable'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
try:
expense.mark_as_reimbursed()
if safe_commit(db):
flash(_('Expense marked as reimbursed'), 'success')
log_event('expense_reimbursed', user_id=current_user.id, expense_id=expense_id)
track_event(current_user.id, 'expense.reimbursed', {'expense_id': expense_id})
else:
flash(_('Error marking expense as reimbursed'), 'error')
except Exception as e:
current_app.logger.error(f"Error marking expense as reimbursed: {e}")
flash(_('Error marking expense as reimbursed'), 'error')
return redirect(url_for('expenses.view_expense', expense_id=expense_id))
@expenses_bp.route('/expenses/export')
@login_required
def export_expenses():
"""Export expenses to CSV"""
# Get filter parameters (same as list_expenses)
status = request.args.get('status', '').strip()
category = request.args.get('category', '').strip()
project_id = request.args.get('project_id', type=int)
client_id = request.args.get('client_id', type=int)
user_id = request.args.get('user_id', type=int)
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
# Build query
query = Expense.query
# Non-admin users can only export their own expenses
if not current_user.is_admin:
query = query.filter(Expense.user_id == current_user.id)
# Apply filters
if status:
query = query.filter(Expense.status == status)
if category:
query = query.filter(Expense.category == category)
if project_id:
query = query.filter(Expense.project_id == project_id)
if client_id:
query = query.filter(Expense.client_id == client_id)
if user_id and current_user.is_admin:
query = query.filter(Expense.user_id == user_id)
if start_date:
try:
start = datetime.strptime(start_date, '%Y-%m-%d').date()
query = query.filter(Expense.expense_date >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d').date()
query = query.filter(Expense.expense_date <= end)
except ValueError:
pass
expenses = query.order_by(Expense.expense_date.desc()).all()
# Create CSV
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
'Date', 'Title', 'Category', 'Amount', 'Tax', 'Total', 'Currency',
'Status', 'Vendor', 'Payment Method', 'Project', 'Client', 'User',
'Billable', 'Reimbursable', 'Invoiced', 'Receipt Number', 'Notes'
])
# Write data
for expense in expenses:
writer.writerow([
expense.expense_date.isoformat() if expense.expense_date else '',
expense.title,
expense.category,
float(expense.amount),
float(expense.tax_amount) if expense.tax_amount else 0,
float(expense.total_amount),
expense.currency_code,
expense.status,
expense.vendor or '',
expense.payment_method or '',
expense.project.name if expense.project else '',
expense.client.name if expense.client else '',
expense.user.username if expense.user else '',
'Yes' if expense.billable else 'No',
'Yes' if expense.reimbursable else 'No',
'Yes' if expense.invoiced else 'No',
expense.receipt_number or '',
expense.notes or ''
])
# Prepare response
output.seek(0)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'expenses_{timestamp}.csv'
# Track export
log_event('expenses_exported', user_id=current_user.id, count=len(expenses))
track_event(current_user.id, 'expenses.exported', {'count': len(expenses)})
return send_file(
io.BytesIO(output.getvalue().encode('utf-8')),
mimetype='text/csv',
as_attachment=True,
download_name=filename
)
@expenses_bp.route('/expenses/dashboard')
@login_required
def dashboard():
"""Expense dashboard with analytics"""
# Track page view
from app import track_page_view
track_page_view("expenses_dashboard")
# Date range - default to current month
today = date.today()
start_date = date(today.year, today.month, 1)
end_date = today
# Get date range from query params if provided
start_date_str = request.args.get('start_date', '').strip()
end_date_str = request.args.get('end_date', '').strip()
if start_date_str:
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
except ValueError:
pass
if end_date_str:
try:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
pass
# Build base query
if current_user.is_admin:
query = Expense.query
else:
query = Expense.query.filter_by(user_id=current_user.id)
# Apply date filter
query = query.filter(
Expense.expense_date >= start_date,
Expense.expense_date <= end_date
)
# Get statistics
total_expenses = query.count()
# Total amount
total_amount_query = db.session.query(
db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0))
).filter(Expense.expense_date >= start_date, Expense.expense_date <= end_date)
if not current_user.is_admin:
total_amount_query = total_amount_query.filter(Expense.user_id == current_user.id)
total_amount = total_amount_query.scalar() or 0
# By status
pending_count = query.filter_by(status='pending').count()
approved_count = query.filter_by(status='approved').count()
rejected_count = query.filter_by(status='rejected').count()
reimbursed_count = query.filter_by(status='reimbursed').count()
# Pending reimbursement
pending_reimbursement = query.filter(
Expense.status == 'approved',
Expense.reimbursable == True,
Expense.reimbursed == False
).count()
# By category
category_stats = Expense.get_expenses_by_category(
user_id=None if current_user.is_admin else current_user.id,
start_date=start_date,
end_date=end_date
)
# Recent expenses
recent_expenses = query.order_by(Expense.expense_date.desc()).limit(10).all()
return render_template(
'expenses/dashboard.html',
total_expenses=total_expenses,
total_amount=float(total_amount),
pending_count=pending_count,
approved_count=approved_count,
rejected_count=rejected_count,
reimbursed_count=reimbursed_count,
pending_reimbursement=pending_reimbursement,
category_stats=category_stats,
recent_expenses=recent_expenses,
start_date=start_date.isoformat(),
end_date=end_date.isoformat()
)
# API endpoints
@expenses_bp.route('/api/expenses', methods=['GET'])
@login_required
def api_list_expenses():
"""API endpoint to list expenses"""
# Similar filters as list_expenses
status = request.args.get('status', '').strip()
category = request.args.get('category', '').strip()
project_id = request.args.get('project_id', type=int)
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
# Build query
query = Expense.query
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
if status:
query = query.filter(Expense.status == status)
if category:
query = query.filter(Expense.category == category)
if project_id:
query = query.filter(Expense.project_id == project_id)
if start_date:
try:
start = datetime.strptime(start_date, '%Y-%m-%d').date()
query = query.filter(Expense.expense_date >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d').date()
query = query.filter(Expense.expense_date <= end)
except ValueError:
pass
expenses = query.order_by(Expense.expense_date.desc()).all()
return jsonify({
'expenses': [expense.to_dict() for expense in expenses],
'count': len(expenses)
})
@expenses_bp.route('/api/expenses/<int:expense_id>', methods=['GET'])
@login_required
def api_get_expense(expense_id):
"""API endpoint to get a single expense"""
expense = Expense.query.get_or_404(expense_id)
# Check permission
if not current_user.is_admin and expense.user_id != current_user.id:
return jsonify({'error': 'Permission denied'}), 403
return jsonify(expense.to_dict())

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_required, current_user
from app.models import User, Project, TimeEntry, Settings
from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal
from datetime import datetime, timedelta
import pytz
from app import db, track_page_view
@@ -73,6 +73,11 @@ def dashboard():
if e.billable and e.project.billable:
project_hours[e.project.id]['billable_hours'] += e.duration_hours
top_projects = sorted(project_hours.values(), key=lambda x: x['hours'], reverse=True)[:5]
# Get current week goal
current_week_goal = WeeklyTimeGoal.get_current_week_goal(current_user.id)
if current_week_goal:
current_week_goal.update_status()
return render_template('main/dashboard.html',
active_timer=active_timer,
@@ -81,7 +86,8 @@ def dashboard():
today_hours=today_hours,
week_hours=week_hours,
month_hours=month_hours,
top_projects=top_projects)
top_projects=top_projects,
current_week_goal=current_week_goal)
@main_bp.route('/_health')
def health_check():

274
app/routes/permissions.py Normal file
View File

@@ -0,0 +1,274 @@
"""Routes for role and permission management (admin only)"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, limiter
from app.models import Permission, Role, User
from app.routes.admin import admin_required
from app.utils.db import safe_commit
from sqlalchemy.exc import IntegrityError
permissions_bp = Blueprint('permissions', __name__)
@permissions_bp.route('/admin/roles')
@login_required
@admin_required
def list_roles():
"""List all roles"""
# Check if user has permission to view roles
if not current_user.is_admin and not current_user.has_permission('view_permissions'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
roles = Role.query.order_by(Role.name).all()
return render_template('admin/roles/list.html', roles=roles)
@permissions_bp.route('/admin/roles/create', methods=['GET', 'POST'])
@login_required
@admin_required
def create_role():
"""Create a new role"""
# Check if user has permission to manage roles
if not current_user.is_admin and not current_user.has_permission('manage_roles'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('Role name is required'), 'error')
return render_template('admin/roles/form.html', role=None, all_permissions=Permission.query.all())
# Check if role already exists
if Role.query.filter_by(name=name).first():
flash(_('A role with this name already exists'), 'error')
return render_template('admin/roles/form.html', role=None, all_permissions=Permission.query.all())
# Create role
role = Role(name=name, description=description, is_system_role=False)
db.session.add(role)
# Assign selected permissions
permission_ids = request.form.getlist('permissions')
for perm_id in permission_ids:
permission = Permission.query.get(int(perm_id))
if permission:
role.add_permission(permission)
if not safe_commit('create_role', {'name': name}):
flash(_('Could not create role due to a database error'), 'error')
return render_template('admin/roles/form.html', role=None, all_permissions=Permission.query.all())
flash(_('Role created successfully'), 'success')
return redirect(url_for('permissions.list_roles'))
# GET request
all_permissions = Permission.query.order_by(Permission.category, Permission.name).all()
return render_template('admin/roles/form.html', role=None, all_permissions=all_permissions)
@permissions_bp.route('/admin/roles/<int:role_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_role(role_id):
"""Edit an existing role"""
# Check if user has permission to manage roles
if not current_user.is_admin and not current_user.has_permission('manage_roles'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
role = Role.query.get_or_404(role_id)
# Prevent editing system roles
if role.is_system_role:
flash(_('System roles cannot be edited'), 'warning')
return redirect(url_for('permissions.view_role', role_id=role.id))
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
if not name:
flash(_('Role name is required'), 'error')
return render_template('admin/roles/form.html', role=role, all_permissions=Permission.query.all())
# Check if name is taken by another role
existing = Role.query.filter_by(name=name).first()
if existing and existing.id != role.id:
flash(_('A role with this name already exists'), 'error')
return render_template('admin/roles/form.html', role=role, all_permissions=Permission.query.all())
# Update role
role.name = name
role.description = description
# Update permissions
permission_ids = request.form.getlist('permissions')
# Remove all current permissions
role.permissions = []
# Add selected permissions
for perm_id in permission_ids:
permission = Permission.query.get(int(perm_id))
if permission:
role.add_permission(permission)
if not safe_commit('edit_role', {'role_id': role.id}):
flash(_('Could not update role due to a database error'), 'error')
return render_template('admin/roles/form.html', role=role, all_permissions=Permission.query.all())
flash(_('Role updated successfully'), 'success')
return redirect(url_for('permissions.view_role', role_id=role.id))
# GET request
all_permissions = Permission.query.order_by(Permission.category, Permission.name).all()
return render_template('admin/roles/form.html', role=role, all_permissions=all_permissions)
@permissions_bp.route('/admin/roles/<int:role_id>')
@login_required
@admin_required
def view_role(role_id):
"""View role details"""
# Check if user has permission to view roles
if not current_user.is_admin and not current_user.has_permission('view_permissions'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
role = Role.query.get_or_404(role_id)
users = role.users.all()
return render_template('admin/roles/view.html', role=role, users=users)
@permissions_bp.route('/admin/roles/<int:role_id>/delete', methods=['POST'])
@login_required
@admin_required
@limiter.limit("10 per minute")
def delete_role(role_id):
"""Delete a role"""
# Check if user has permission to manage roles
if not current_user.is_admin and not current_user.has_permission('manage_roles'):
flash(_('You do not have permission to perform this action'), 'error')
return redirect(url_for('main.dashboard'))
role = Role.query.get_or_404(role_id)
# Prevent deleting system roles
if role.is_system_role:
flash(_('System roles cannot be deleted'), 'error')
return redirect(url_for('permissions.list_roles'))
# Check if role is assigned to any users
if role.users.count() > 0:
flash(_('Cannot delete role that is assigned to users. Please reassign users first.'), 'error')
return redirect(url_for('permissions.view_role', role_id=role.id))
role_name = role.name
db.session.delete(role)
if not safe_commit('delete_role', {'role_id': role.id}):
flash(_('Could not delete role due to a database error'), 'error')
return redirect(url_for('permissions.list_roles'))
flash(_('Role "%(name)s" deleted successfully', name=role_name), 'success')
return redirect(url_for('permissions.list_roles'))
@permissions_bp.route('/admin/permissions')
@login_required
@admin_required
def list_permissions():
"""List all permissions"""
# Check if user has permission to view permissions
if not current_user.is_admin and not current_user.has_permission('view_permissions'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
# Group permissions by category
permissions = Permission.query.order_by(Permission.category, Permission.name).all()
# Organize by category
permissions_by_category = {}
for perm in permissions:
category = perm.category or 'general'
if category not in permissions_by_category:
permissions_by_category[category] = []
permissions_by_category[category].append(perm)
return render_template('admin/permissions/list.html', permissions_by_category=permissions_by_category)
@permissions_bp.route('/admin/users/<int:user_id>/roles', methods=['GET', 'POST'])
@login_required
@admin_required
def manage_user_roles(user_id):
"""Manage roles for a specific user"""
# Check if user has permission to manage user roles
if not current_user.is_admin and not current_user.has_permission('manage_user_roles'):
flash(_('You do not have permission to access this page'), 'error')
return redirect(url_for('main.dashboard'))
user = User.query.get_or_404(user_id)
if request.method == 'POST':
# Get selected role IDs
role_ids = request.form.getlist('roles')
# Clear current roles
user.roles = []
# Assign selected roles
for role_id in role_ids:
role = Role.query.get(int(role_id))
if role:
user.add_role(role)
if not safe_commit('manage_user_roles', {'user_id': user.id}):
flash(_('Could not update user roles due to a database error'), 'error')
return render_template('admin/users/roles.html', user=user, all_roles=Role.query.all())
flash(_('User roles updated successfully'), 'success')
return redirect(url_for('admin.edit_user', user_id=user.id))
# GET request
all_roles = Role.query.order_by(Role.name).all()
return render_template('admin/users/roles.html', user=user, all_roles=all_roles)
@permissions_bp.route('/api/users/<int:user_id>/permissions')
@login_required
def get_user_permissions(user_id):
"""API endpoint to get user's effective permissions"""
# Users can view their own permissions, admins can view any user's permissions
if current_user.id != user_id and not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
user = User.query.get_or_404(user_id)
permissions = user.get_all_permissions()
return jsonify({
'user_id': user.id,
'username': user.username,
'roles': [{'id': r.id, 'name': r.name} for r in user.roles],
'permissions': [{'id': p.id, 'name': p.name, 'description': p.description} for p in permissions]
})
@permissions_bp.route('/api/roles/<int:role_id>/permissions')
@login_required
@admin_required
def get_role_permissions(role_id):
"""API endpoint to get role's permissions"""
role = Role.query.get_or_404(role_id)
return jsonify({
'role_id': role.id,
'name': role.name,
'description': role.description,
'is_system_role': role.is_system_role,
'permissions': [{'id': p.id, 'name': p.name, 'description': p.description, 'category': p.category} for p in role.permissions]
})

View File

@@ -6,6 +6,7 @@ from app.models import Project, TimeEntry, Task, Client, ProjectCost, KanbanColu
from datetime import datetime
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required, permission_required
from app.utils.posthog_funnels import (
track_onboarding_first_project,
track_project_setup_started,
@@ -88,15 +89,9 @@ def list_projects():
@projects_bp.route('/projects/create', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('create_projects')
def create_project():
"""Create a new project"""
if not current_user.is_admin:
try:
current_app.logger.warning("Non-admin user attempted to create project: user=%s", current_user.username)
except Exception:
pass
flash('Only administrators can create projects', 'error')
return redirect(url_for('projects.list_projects'))
# Track project setup started when user opens the form
if request.method == 'GET':
@@ -336,12 +331,9 @@ def view_project(project_id):
@projects_bp.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('edit_projects')
def edit_project(project_id):
"""Edit project details"""
if not current_user.is_admin:
flash('Only administrators can edit projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
if request.method == 'POST':
@@ -428,38 +420,85 @@ def edit_project(project_id):
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
@projects_bp.route('/projects/<int:project_id>/archive', methods=['POST'])
@projects_bp.route('/projects/<int:project_id>/archive', methods=['GET', 'POST'])
@login_required
def archive_project(project_id):
"""Archive a project"""
if not current_user.is_admin:
flash('Only administrators can archive projects', 'error')
"""Archive a project with optional reason"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin and not current_user.has_permission('archive_projects'):
flash('You do not have permission to archive projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
if request.method == 'GET':
# Show archive form
return render_template('projects/archive.html', project=project)
if project.status == 'archived':
flash('Project is already archived', 'info')
else:
project.archive()
reason = request.form.get('reason', '').strip()
project.archive(user_id=current_user.id, reason=reason if reason else None)
# Log the archiving
log_event("project.archived",
user_id=current_user.id,
project_id=project.id,
reason=reason if reason else None)
track_event(current_user.id, "project.archived", {
"project_id": project.id,
"has_reason": bool(reason)
})
# Log activity
Activity.log(
user_id=current_user.id,
action='archived',
entity_type='project',
entity_id=project.id,
entity_name=project.name,
description=f'Archived project "{project.name}"' + (f': {reason}' if reason else ''),
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
flash(f'Project "{project.name}" archived successfully', 'success')
return redirect(url_for('projects.list_projects'))
return redirect(url_for('projects.list_projects', status='archived'))
@projects_bp.route('/projects/<int:project_id>/unarchive', methods=['POST'])
@login_required
def unarchive_project(project_id):
"""Unarchive a project"""
if not current_user.is_admin:
flash('Only administrators can unarchive projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin and not current_user.has_permission('archive_projects'):
flash('You do not have permission to unarchive projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
if project.status == 'active':
flash('Project is already active', 'info')
else:
project.unarchive()
# Log the unarchiving
log_event("project.unarchived", user_id=current_user.id, project_id=project.id)
track_event(current_user.id, "project.unarchived", {"project_id": project.id})
# Log activity
Activity.log(
user_id=current_user.id,
action='unarchived',
entity_type='project',
entity_id=project.id,
entity_name=project.name,
description=f'Unarchived project "{project.name}"',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
flash(f'Project "{project.name}" unarchived successfully', 'success')
return redirect(url_for('projects.list_projects'))
@@ -468,12 +507,13 @@ def unarchive_project(project_id):
@login_required
def deactivate_project(project_id):
"""Mark a project as inactive"""
if not current_user.is_admin:
flash('Only administrators can deactivate projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin and not current_user.has_permission('edit_projects'):
flash('You do not have permission to deactivate projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
if project.status == 'inactive':
flash('Project is already inactive', 'info')
else:
@@ -489,12 +529,13 @@ def deactivate_project(project_id):
@login_required
def activate_project(project_id):
"""Activate a project"""
if not current_user.is_admin:
flash('Only administrators can activate projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin and not current_user.has_permission('edit_projects'):
flash('You do not have permission to activate projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
if project.status == 'active':
flash('Project is already active', 'info')
else:
@@ -508,12 +549,9 @@ def activate_project(project_id):
@projects_bp.route('/projects/<int:project_id>/delete', methods=['POST'])
@login_required
@admin_or_permission_required('delete_projects')
def delete_project(project_id):
"""Delete a project (only if no time entries exist)"""
if not current_user.is_admin:
flash('Only administrators can delete projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
# Check if project has time entries
@@ -534,8 +572,9 @@ def delete_project(project_id):
@login_required
def bulk_delete_projects():
"""Delete multiple projects at once"""
if not current_user.is_admin:
flash('Only administrators can delete projects', 'error')
# Check permissions
if not current_user.is_admin and not current_user.has_permission('delete_projects'):
flash('You do not have permission to delete projects', 'error')
return redirect(url_for('projects.list_projects'))
project_ids = request.form.getlist('project_ids[]')
@@ -599,12 +638,14 @@ def bulk_delete_projects():
@login_required
def bulk_status_change():
"""Change status for multiple projects at once"""
if not current_user.is_admin:
flash('Only administrators can change project status', 'error')
# Check permissions
if not current_user.is_admin and not current_user.has_permission('edit_projects'):
flash('You do not have permission to change project status', 'error')
return redirect(url_for('projects.list_projects'))
project_ids = request.form.getlist('project_ids[]')
new_status = request.form.get('new_status', '').strip()
archive_reason = request.form.get('archive_reason', '').strip() if new_status == 'archived' else None
if not project_ids:
flash('No projects selected', 'warning')
@@ -625,15 +666,44 @@ def bulk_status_change():
if not project:
continue
# Update status
project.status = new_status
project.updated_at = datetime.utcnow()
# Update status based on type
if new_status == 'archived':
# Use the enhanced archive method
project.status = 'archived'
project.archived_at = datetime.utcnow()
project.archived_by = current_user.id
project.archived_reason = archive_reason if archive_reason else None
project.updated_at = datetime.utcnow()
elif new_status == 'active':
# Clear archiving metadata when activating
project.status = 'active'
project.archived_at = None
project.archived_by = None
project.archived_reason = None
project.updated_at = datetime.utcnow()
else:
# Just update status for inactive
project.status = new_status
project.updated_at = datetime.utcnow()
updated_count += 1
# Log the status change
log_event(f"project.status_changed_{new_status}", user_id=current_user.id, project_id=project.id)
track_event(current_user.id, "project.status_changed", {"project_id": project.id, "new_status": new_status})
# Log activity
Activity.log(
user_id=current_user.id,
action=f'status_changed_{new_status}',
entity_type='project',
entity_id=project.id,
entity_name=project.name,
description=f'Changed project "{project.name}" status to {new_status}' + (f': {archive_reason}' if new_status == 'archived' and archive_reason else ''),
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
except Exception as e:
errors.append(f"ID {project_id_str}: {str(e)}")

View File

@@ -28,11 +28,21 @@ def start_timer():
current_app.logger.warning("Start timer failed: missing project_id")
return redirect(url_for('main.dashboard'))
# Check if project exists and is active
project = Project.query.filter_by(id=project_id, status='active').first()
# Check if project exists
project = Project.query.get(project_id)
if not project:
flash('Invalid project selected', 'error')
current_app.logger.warning("Start timer failed: invalid or inactive project_id=%s", project_id)
flash(_('Invalid project selected'), 'error')
current_app.logger.warning("Start timer failed: invalid project_id=%s", project_id)
return redirect(url_for('main.dashboard'))
# Check if project is active (not archived or inactive)
if project.status == 'archived':
flash(_('Cannot start timer for an archived project. Please unarchive the project first.'), 'error')
current_app.logger.warning("Start timer failed: project_id=%s is archived", project_id)
return redirect(url_for('main.dashboard'))
elif project.status != 'active':
flash(_('Cannot start timer for an inactive project'), 'error')
current_app.logger.warning("Start timer failed: project_id=%s is not active", project_id)
return redirect(url_for('main.dashboard'))
# If a task is provided, validate it belongs to the project
@@ -118,11 +128,21 @@ def start_timer_for_project(project_id):
task_id = request.args.get('task_id', type=int)
current_app.logger.info("GET /timer/start/%s user=%s task_id=%s", project_id, current_user.username, task_id)
# Check if project exists and is active
project = Project.query.filter_by(id=project_id, status='active').first()
# Check if project exists
project = Project.query.get(project_id)
if not project:
flash('Invalid project selected', 'error')
current_app.logger.warning("Start timer (GET) failed: invalid or inactive project_id=%s", project_id)
flash(_('Invalid project selected'), 'error')
current_app.logger.warning("Start timer (GET) failed: invalid project_id=%s", project_id)
return redirect(url_for('main.dashboard'))
# Check if project is active (not archived or inactive)
if project.status == 'archived':
flash(_('Cannot start timer for an archived project. Please unarchive the project first.'), 'error')
current_app.logger.warning("Start timer (GET) failed: project_id=%s is archived", project_id)
return redirect(url_for('main.dashboard'))
elif project.status != 'active':
flash(_('Cannot start timer for an inactive project'), 'error')
current_app.logger.warning("Start timer (GET) failed: project_id=%s is not active", project_id)
return redirect(url_for('main.dashboard'))
# Check if user already has an active timer
@@ -423,10 +443,20 @@ def manual_entry():
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
# Check if project exists and is active
project = Project.query.filter_by(id=project_id, status='active').first()
# Check if project exists
project = Project.query.get(project_id)
if not project:
flash('Invalid project selected', 'error')
flash(_('Invalid project selected'), 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
# Check if project is active (not archived or inactive)
if project.status == 'archived':
flash(_('Cannot create time entries for an archived project. Please unarchive the project first.'), 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
elif project.status != 'active':
flash(_('Cannot create time entries for an inactive project'), 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
@@ -531,10 +561,20 @@ def bulk_entry():
return render_template('timer/bulk_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
# Check if project exists and is active
project = Project.query.filter_by(id=project_id, status='active').first()
# Check if project exists
project = Project.query.get(project_id)
if not project:
flash('Invalid project selected', 'error')
flash(_('Invalid project selected'), 'error')
return render_template('timer/bulk_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
# Check if project is active (not archived or inactive)
if project.status == 'archived':
flash(_('Cannot create time entries for an archived project. Please unarchive the project first.'), 'error')
return render_template('timer/bulk_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
elif project.status != 'active':
flash(_('Cannot create time entries for an inactive project'), 'error')
return render_template('timer/bulk_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)

View File

@@ -86,6 +86,17 @@ def settings():
if preferred_language:
current_user.preferred_language = preferred_language
# Time rounding preferences
current_user.time_rounding_enabled = 'time_rounding_enabled' in request.form
time_rounding_minutes = request.form.get('time_rounding_minutes', type=int)
if time_rounding_minutes and time_rounding_minutes in [1, 5, 10, 15, 30, 60]:
current_user.time_rounding_minutes = time_rounding_minutes
time_rounding_method = request.form.get('time_rounding_method')
if time_rounding_method in ['nearest', 'up', 'down']:
current_user.time_rounding_method = time_rounding_method
# Save changes
if safe_commit(db.session):
# Log activity
@@ -122,10 +133,17 @@ def settings():
'fi': 'Suomi'
})
# Get time rounding options
from app.utils.time_rounding import get_available_rounding_intervals, get_available_rounding_methods
rounding_intervals = get_available_rounding_intervals()
rounding_methods = get_available_rounding_methods()
return render_template('user/settings.html',
user=current_user,
timezones=timezones,
languages=languages)
languages=languages,
rounding_intervals=rounding_intervals,
rounding_methods=rounding_methods)
@user_bp.route('/api/preferences', methods=['PATCH'])

399
app/routes/weekly_goals.py Normal file
View File

@@ -0,0 +1,399 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import WeeklyTimeGoal, TimeEntry
from app.utils.db import safe_commit
from datetime import datetime, timedelta
from sqlalchemy import func
weekly_goals_bp = Blueprint('weekly_goals', __name__)
@weekly_goals_bp.route('/goals')
@login_required
def index():
"""Display weekly goals overview page"""
current_app.logger.info(f"GET /goals user={current_user.username}")
# Get current week goal
current_goal = WeeklyTimeGoal.get_current_week_goal(current_user.id)
# Get all goals for the user, ordered by week
all_goals = WeeklyTimeGoal.query.filter_by(
user_id=current_user.id
).order_by(
WeeklyTimeGoal.week_start_date.desc()
).limit(12).all() # Show last 12 weeks
# Update status for all goals
for goal in all_goals:
goal.update_status()
# Calculate statistics
stats = {
'total_goals': len(all_goals),
'completed': sum(1 for g in all_goals if g.status == 'completed'),
'failed': sum(1 for g in all_goals if g.status == 'failed'),
'active': sum(1 for g in all_goals if g.status == 'active'),
'completion_rate': 0
}
if stats['total_goals'] > 0:
completed_or_failed = stats['completed'] + stats['failed']
if completed_or_failed > 0:
stats['completion_rate'] = round((stats['completed'] / completed_or_failed) * 100, 1)
# Track page view
track_event(
user_id=current_user.id,
event_name='weekly_goals_viewed',
properties={'has_current_goal': current_goal is not None}
)
return render_template(
'weekly_goals/index.html',
current_goal=current_goal,
goals=all_goals,
stats=stats
)
@weekly_goals_bp.route('/goals/create', methods=['GET', 'POST'])
@login_required
def create():
"""Create a new weekly time goal"""
if request.method == 'GET':
current_app.logger.info(f"GET /goals/create user={current_user.username}")
return render_template('weekly_goals/create.html')
# POST request
current_app.logger.info(f"POST /goals/create user={current_user.username}")
target_hours = request.form.get('target_hours', type=float)
week_start_date_str = request.form.get('week_start_date')
notes = request.form.get('notes', '').strip()
if not target_hours or target_hours <= 0:
flash(_('Please enter a valid target hours (greater than 0)'), 'error')
return redirect(url_for('weekly_goals.create'))
# Parse week start date
week_start_date = None
if week_start_date_str:
try:
week_start_date = datetime.strptime(week_start_date_str, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return redirect(url_for('weekly_goals.create'))
# Check if goal already exists for this week
if week_start_date:
existing_goal = WeeklyTimeGoal.query.filter(
WeeklyTimeGoal.user_id == current_user.id,
WeeklyTimeGoal.week_start_date == week_start_date,
WeeklyTimeGoal.status != 'cancelled'
).first()
if existing_goal:
flash(_('A goal already exists for this week. Please edit the existing goal instead.'), 'warning')
return redirect(url_for('weekly_goals.edit', goal_id=existing_goal.id))
# Create new goal
goal = WeeklyTimeGoal(
user_id=current_user.id,
target_hours=target_hours,
week_start_date=week_start_date,
notes=notes
)
db.session.add(goal)
if safe_commit(db.session):
flash(_('Weekly time goal created successfully!'), 'success')
log_event(
'weekly_goal.created',
user_id=current_user.id,
resource_type='weekly_goal',
resource_id=goal.id,
target_hours=target_hours,
week_label=goal.week_label
)
track_event(
user_id=current_user.id,
event_name='weekly_goal_created',
properties={'target_hours': target_hours, 'week_label': goal.week_label}
)
return redirect(url_for('weekly_goals.index'))
else:
flash(_('Failed to create goal. Please try again.'), 'error')
return redirect(url_for('weekly_goals.create'))
@weekly_goals_bp.route('/goals/<int:goal_id>')
@login_required
def view(goal_id):
"""View details of a specific weekly goal"""
current_app.logger.info(f"GET /goals/{goal_id} user={current_user.username}")
goal = WeeklyTimeGoal.query.get_or_404(goal_id)
# Ensure user can only view their own goals
if goal.user_id != current_user.id:
flash(_('You do not have permission to view this goal'), 'error')
return redirect(url_for('weekly_goals.index'))
# Update goal status
goal.update_status()
# Get time entries for this week
time_entries = TimeEntry.query.filter(
TimeEntry.user_id == current_user.id,
TimeEntry.end_time.isnot(None),
func.date(TimeEntry.start_time) >= goal.week_start_date,
func.date(TimeEntry.start_time) <= goal.week_end_date
).order_by(TimeEntry.start_time.desc()).all()
# Calculate daily breakdown
daily_hours = {}
for entry in time_entries:
entry_date = entry.start_time.date()
if entry_date not in daily_hours:
daily_hours[entry_date] = 0
daily_hours[entry_date] += entry.duration_seconds / 3600
# Fill in missing days with 0
current_date = goal.week_start_date
while current_date <= goal.week_end_date:
if current_date not in daily_hours:
daily_hours[current_date] = 0
current_date += timedelta(days=1)
# Sort by date
daily_hours = dict(sorted(daily_hours.items()))
track_event(
user_id=current_user.id,
event_name='weekly_goal_viewed',
properties={'goal_id': goal_id, 'week_label': goal.week_label}
)
return render_template(
'weekly_goals/view.html',
goal=goal,
time_entries=time_entries,
daily_hours=daily_hours
)
@weekly_goals_bp.route('/goals/<int:goal_id>/edit', methods=['GET', 'POST'])
@login_required
def edit(goal_id):
"""Edit a weekly time goal"""
goal = WeeklyTimeGoal.query.get_or_404(goal_id)
# Ensure user can only edit their own goals
if goal.user_id != current_user.id:
flash(_('You do not have permission to edit this goal'), 'error')
return redirect(url_for('weekly_goals.index'))
if request.method == 'GET':
current_app.logger.info(f"GET /goals/{goal_id}/edit user={current_user.username}")
return render_template('weekly_goals/edit.html', goal=goal)
# POST request
current_app.logger.info(f"POST /goals/{goal_id}/edit user={current_user.username}")
target_hours = request.form.get('target_hours', type=float)
notes = request.form.get('notes', '').strip()
status = request.form.get('status')
if not target_hours or target_hours <= 0:
flash(_('Please enter a valid target hours (greater than 0)'), 'error')
return redirect(url_for('weekly_goals.edit', goal_id=goal_id))
# Update goal
old_target = goal.target_hours
goal.target_hours = target_hours
goal.notes = notes
if status and status in ['active', 'completed', 'failed', 'cancelled']:
goal.status = status
if safe_commit(db.session):
flash(_('Weekly time goal updated successfully!'), 'success')
log_event(
'weekly_goal.updated',
user_id=current_user.id,
resource_type='weekly_goal',
resource_id=goal.id,
old_target=old_target,
new_target=target_hours,
week_label=goal.week_label
)
track_event(
user_id=current_user.id,
event_name='weekly_goal_updated',
properties={'goal_id': goal_id, 'new_target': target_hours}
)
return redirect(url_for('weekly_goals.view', goal_id=goal_id))
else:
flash(_('Failed to update goal. Please try again.'), 'error')
return redirect(url_for('weekly_goals.edit', goal_id=goal_id))
@weekly_goals_bp.route('/goals/<int:goal_id>/delete', methods=['POST'])
@login_required
def delete(goal_id):
"""Delete a weekly time goal"""
current_app.logger.info(f"POST /goals/{goal_id}/delete user={current_user.username}")
goal = WeeklyTimeGoal.query.get_or_404(goal_id)
# Ensure user can only delete their own goals
if goal.user_id != current_user.id:
flash(_('You do not have permission to delete this goal'), 'error')
return redirect(url_for('weekly_goals.index'))
week_label = goal.week_label
db.session.delete(goal)
if safe_commit(db.session):
flash(_('Weekly time goal deleted successfully'), 'success')
log_event(
'weekly_goal.deleted',
user_id=current_user.id,
resource_type='weekly_goal',
resource_id=goal_id,
week_label=week_label
)
track_event(
user_id=current_user.id,
event_name='weekly_goal_deleted',
properties={'goal_id': goal_id}
)
else:
flash(_('Failed to delete goal. Please try again.'), 'error')
return redirect(url_for('weekly_goals.index'))
# API Endpoints
@weekly_goals_bp.route('/api/goals/current')
@login_required
def api_current_goal():
"""API endpoint to get current week's goal"""
current_app.logger.info(f"GET /api/goals/current user={current_user.username}")
goal = WeeklyTimeGoal.get_current_week_goal(current_user.id)
if goal:
goal.update_status()
return jsonify(goal.to_dict())
else:
return jsonify({'error': 'No goal set for current week'}), 404
@weekly_goals_bp.route('/api/goals')
@login_required
def api_list_goals():
"""API endpoint to list all goals for current user"""
current_app.logger.info(f"GET /api/goals user={current_user.username}")
limit = request.args.get('limit', 12, type=int)
status_filter = request.args.get('status')
query = WeeklyTimeGoal.query.filter_by(user_id=current_user.id)
if status_filter:
query = query.filter_by(status=status_filter)
goals = query.order_by(
WeeklyTimeGoal.week_start_date.desc()
).limit(limit).all()
# Update status for all goals
for goal in goals:
goal.update_status()
return jsonify([goal.to_dict() for goal in goals])
@weekly_goals_bp.route('/api/goals/<int:goal_id>')
@login_required
def api_get_goal(goal_id):
"""API endpoint to get a specific goal"""
current_app.logger.info(f"GET /api/goals/{goal_id} user={current_user.username}")
goal = WeeklyTimeGoal.query.get_or_404(goal_id)
# Ensure user can only view their own goals
if goal.user_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
goal.update_status()
return jsonify(goal.to_dict())
@weekly_goals_bp.route('/api/goals/stats')
@login_required
def api_stats():
"""API endpoint to get goal statistics"""
current_app.logger.info(f"GET /api/goals/stats user={current_user.username}")
# Get all goals for the user
goals = WeeklyTimeGoal.query.filter_by(
user_id=current_user.id
).order_by(
WeeklyTimeGoal.week_start_date.desc()
).all()
# Update status for all goals
for goal in goals:
goal.update_status()
# Calculate statistics
total = len(goals)
completed = sum(1 for g in goals if g.status == 'completed')
failed = sum(1 for g in goals if g.status == 'failed')
active = sum(1 for g in goals if g.status == 'active')
cancelled = sum(1 for g in goals if g.status == 'cancelled')
completion_rate = 0
if total > 0:
completed_or_failed = completed + failed
if completed_or_failed > 0:
completion_rate = round((completed / completed_or_failed) * 100, 1)
# Calculate average target hours
avg_target = 0
if total > 0:
avg_target = round(sum(g.target_hours for g in goals) / total, 2)
# Calculate average actual hours
avg_actual = 0
if total > 0:
avg_actual = round(sum(g.actual_hours for g in goals) / total, 2)
# Get current streak (consecutive weeks with completed goals)
current_streak = 0
for goal in goals:
if goal.status == 'completed':
current_streak += 1
elif goal.status in ['failed', 'cancelled']:
break
return jsonify({
'total_goals': total,
'completed': completed,
'failed': failed,
'active': active,
'cancelled': cancelled,
'completion_rate': completion_rate,
'average_target_hours': avg_target,
'average_actual_hours': avg_actual,
'current_streak': current_streak
})

View File

@@ -18,8 +18,9 @@
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Admin Sections</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<a href="{{ url_for('admin.list_users') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">Manage Users</a>
<a href="{{ url_for('permissions.list_roles') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">Roles & Permissions</a>
<a href="{{ url_for('admin.settings') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">Settings</a>
<a href="{{ url_for('admin.system_info') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">System Info</a>
</div>

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block content %}
<div class="mb-6">
<a href="{{ url_for('permissions.list_roles') }}" class="text-primary hover:underline flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
</svg>
{{ _('Back to Roles') }}
</a>
</div>
<div class="mb-6">
<h1 class="text-2xl font-bold">{{ _('System Permissions') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('All available permissions in the system') }}</p>
</div>
<div class="space-y-6">
{% for category, permissions in permissions_by_category.items() %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow">
<div class="bg-primary/10 px-6 py-4 border-b border-border-light dark:border-border-dark">
<h2 class="text-lg font-semibold capitalize">{{ category.replace('_', ' ') }}</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ permissions|length }} {{ _('permissions') }}</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for permission in permissions %}
<div class="flex items-start gap-3 p-3 border border-border-light dark:border-border-dark rounded-lg">
<div class="flex-shrink-0 mt-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
</svg>
</div>
<div class="flex-1">
<h3 class="font-medium text-sm">{{ permission.name.replace('_', ' ').title() }}</h3>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ permission.description or _('No description available') }}
</p>
<div class="mt-2">
<span class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">{{ permission.name }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">{{ _('About Permissions') }}</h3>
<p class="text-sm text-blue-800 dark:text-blue-200">
{{ _('Permissions define what actions users can perform in the system. These permissions are assigned to roles, and roles are assigned to users. This provides a flexible and granular access control system.') }}
</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,116 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="mb-6">
<a href="{{ url_for('permissions.list_roles') }}" class="text-primary hover:underline flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
</svg>
{{ _('Back to Roles') }}
</a>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h1 class="text-2xl font-bold mb-6">
{% if role %}
{{ _('Edit Role') }}: {{ role.name }}
{% else %}
{{ _('Create New Role') }}
{% endif %}
</h1>
<form method="post" class="space-y-6" autocomplete="off">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Role Name') }}</label>
<input type="text"
id="name"
name="name"
value="{{ role.name if role else '' }}"
required
autocomplete="off"
spellcheck="false"
data-form-type="other"
class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('A unique name for this role') }}</p>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Description') }}</label>
<textarea id="description"
name="description"
rows="3"
autocomplete="off"
spellcheck="true"
data-form-type="other"
class="form-input">{{ role.description if role else '' }}</textarea>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Optional description of this role') }}</p>
</div>
<div>
<h3 class="text-lg font-semibold mb-4 text-text-light dark:text-text-dark">{{ _('Permissions') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">{{ _('Select the permissions this role should have:') }}</p>
{% set categories = {} %}
{% for permission in all_permissions %}
{% if permission.category not in categories %}
{% set _ = categories.update({permission.category: []}) %}
{% endif %}
{% set _ = categories[permission.category].append(permission) %}
{% endfor %}
<div class="space-y-6">
{% for category, perms in categories.items() %}
<div class="border border-border-light dark:border-border-dark rounded-lg p-4 bg-bg-light dark:bg-bg-dark">
<div class="flex items-center justify-between mb-3">
<h4 class="font-semibold text-text-light dark:text-text-dark capitalize">{{ category.replace('_', ' ') }}</h4>
<button type="button" class="text-xs text-primary hover:underline" onclick="toggleCategory(this)">{{ _("Toggle All") }}</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{% for permission in perms %}
<label class="flex items-start gap-2 cursor-pointer p-2 rounded hover:bg-bg-hover-light dark:hover:bg-bg-hover-dark transition">
<input type="checkbox" name="permissions" value="{{ permission.id }}"
{% if role and role.has_permission(permission.name) %}checked{% endif %}
class="mt-1 h-4 w-4 text-primary border-border-light dark:border-border-dark rounded focus:ring-primary bg-input-light dark:bg-input-dark">
<div class="flex-1">
<div class="text-sm font-medium text-text-light dark:text-text-dark">{{ permission.name.replace('_', ' ').title() }}</div>
{% if permission.description %}
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ permission.description }}</div>
{% endif %}
</div>
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="flex gap-4">
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-opacity-90 transition">
{% if role %}
{{ _('Update Role') }}
{% else %}
{{ _('Create Role') }}
{% endif %}
</button>
<a href="{{ url_for('permissions.list_roles') }}" class="bg-secondary text-white px-6 py-2 rounded-lg hover:bg-opacity-90 transition">
{{ _('Cancel') }}
</a>
</div>
</form>
</div>
</div>
<script>
// Toggle all checkboxes in a category
function toggleCategory(button) {
const category = button.closest('.border');
const checkboxes = category.querySelectorAll('input[type="checkbox"]');
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
checkboxes.forEach(cb => cb.checked = !allChecked);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ _('Roles & Permissions') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Manage roles and their permissions') }}</p>
</div>
<div class="flex gap-2 mt-4 md:mt-0">
<a href="{{ url_for('permissions.list_permissions') }}" class="bg-secondary text-white px-4 py-2 rounded-lg">{{ _('View Permissions') }}</a>
{% if current_user.is_admin or has_permission('manage_roles') %}
<a href="{{ url_for('permissions.create_role') }}" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Create Role') }}</a>
{% endif %}
</div>
</div>
<!-- Statistics Summary -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow">
<div class="flex items-center">
<div class="flex-shrink-0 bg-primary/10 rounded-full p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total Roles') }}</p>
<p class="text-2xl font-bold">{{ roles|length }}</p>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow">
<div class="flex items-center">
<div class="flex-shrink-0 bg-blue-100 dark:bg-blue-900/30 rounded-full p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('System Roles') }}</p>
<p class="text-2xl font-bold">{{ roles|selectattr('is_system_role')|list|length }}</p>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow">
<div class="flex items-center">
<div class="flex-shrink-0 bg-gray-100 dark:bg-gray-700 rounded-full p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Custom Roles') }}</p>
<p class="text-2xl font-bold">{{ roles|rejectattr('is_system_role')|list|length }}</p>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow">
<div class="flex items-center">
<div class="flex-shrink-0 bg-green-100 dark:bg-green-900/30 rounded-full p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Assigned Users') }}</p>
<p class="text-2xl font-bold">
{% set total_users = namespace(count=0) %}
{% for role in roles %}
{% set total_users.count = total_users.count + role.users.count() %}
{% endfor %}
{{ total_users.count }}
</p>
</div>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-4">{{ _('Role Name') }}</th>
<th class="p-4">{{ _('Description') }}</th>
<th class="p-4">{{ _('Permissions') }}</th>
<th class="p-4">{{ _('Users') }}</th>
<th class="p-4">{{ _('Type') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for role in roles %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-bg-hover-light dark:hover:bg-bg-hover-dark">
<td class="p-4">
<div class="font-semibold">{{ role.name }}</div>
{% if role.is_system_role %}
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Default role') }}</span>
{% endif %}
</td>
<td class="p-4 text-sm text-text-muted-light dark:text-text-muted-dark">{{ role.description or '-' }}</td>
<td class="p-4">
<button type="button"
onclick="document.getElementById('perms-{{ role.id }}').classList.toggle('hidden')"
class="text-primary hover:underline text-sm flex items-center gap-1">
<span>{{ role.permissions|length }} {{ _('permissions') }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</td>
<td class="p-4">
{% set user_count = role.users.count() %}
{% if user_count > 0 %}
<a href="{{ url_for('permissions.view_role', role_id=role.id) }}" class="text-sm font-semibold text-primary hover:underline">
{{ user_count }} {{ _('user') if user_count == 1 else _('users') }}
</a>
{% else %}
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No users') }}</span>
{% endif %}
</td>
<td class="p-4">
{% if role.is_system_role %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ _('System') }}
</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{{ _('Custom') }}
</span>
{% endif %}
</td>
<td class="p-4">
<div class="flex gap-2">
<a href="{{ url_for('permissions.view_role', role_id=role.id) }}" class="text-primary hover:underline text-sm">{{ _('View') }}</a>
{% if not role.is_system_role and (current_user.is_admin or has_permission('manage_roles')) %}
<a href="{{ url_for('permissions.edit_role', role_id=role.id) }}" class="text-primary hover:underline text-sm">{{ _('Edit') }}</a>
<form action="{{ url_for('permissions.delete_role', role_id=role.id) }}" method="post" class="inline" onsubmit="return confirm('{{ _('Are you sure you want to delete this role?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-600 hover:underline text-sm">{{ _('Delete') }}</button>
</form>
{% endif %}
</div>
</td>
</tr>
<!-- Expandable permission details row -->
<tr id="perms-{{ role.id }}" class="hidden bg-background-light dark:bg-background-dark">
<td colspan="6" class="p-4">
<div class="text-sm">
<h4 class="font-semibold mb-2">{{ _('Permissions for') }} {{ role.name }}:</h4>
{% set categories = {} %}
{% for permission in role.permissions %}
{% if permission.category not in categories %}
{% set _ = categories.update({permission.category: []}) %}
{% endif %}
{% set _ = categories[permission.category].append(permission) %}
{% endfor %}
{% if categories %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for category, perms in categories.items() %}
<div class="border border-border-light dark:border-border-dark rounded p-3">
<h5 class="font-semibold text-primary text-xs uppercase mb-2">{{ category.replace('_', ' ') }}</h5>
<ul class="space-y-1">
{% for perm in perms %}
<li class="text-xs flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>{{ perm.name.replace('_', ' ') }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No permissions assigned.') }}</p>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">{{ _('No roles found.') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">{{ _('About Roles & Permissions') }}</h3>
<p class="text-sm text-blue-800 dark:text-blue-200">
{{ _('Roles are collections of permissions that can be assigned to users. System roles are predefined and cannot be deleted or renamed, but custom roles can be created for your specific needs.') }}
</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block content %}
<div class="mb-6">
<a href="{{ url_for('permissions.list_roles') }}" class="text-primary hover:underline flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
</svg>
{{ _('Back to Roles') }}
</a>
</div>
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ role.name }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ role.description or _('No description') }}</p>
</div>
<div class="flex gap-2 mt-4 md:mt-0">
{% if not role.is_system_role and (current_user.is_admin or has_permission('manage_roles')) %}
<a href="{{ url_for('permissions.edit_role', role_id=role.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Edit Role') }}</a>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Role Information') }}</h2>
<dl class="space-y-2">
<div>
<dt class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Type') }}</dt>
<dd class="font-medium">
{% if role.is_system_role %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ _('System Role') }}
</span>
{% else %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{{ _('Custom Role') }}
</span>
{% endif %}
</dd>
</div>
<div>
<dt class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total Permissions') }}</dt>
<dd class="font-medium">{{ role.permissions|length }}</dd>
</div>
<div>
<dt class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Assigned Users') }}</dt>
<dd class="font-medium">{{ users|length }}</dd>
</div>
<div>
<dt class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Created') }}</dt>
<dd class="font-medium">{{ role.created_at.strftime('%Y-%m-%d %H:%M') if role.created_at else '-' }}</dd>
</div>
</dl>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Users with this Role') }}</h2>
{% if users %}
<ul class="space-y-2">
{% for user in users %}
<li class="flex items-center justify-between py-2 border-b border-border-light dark:border-border-dark last:border-0">
<span>{{ user.username }}</span>
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}" class="text-primary hover:underline text-sm">{{ _('View') }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No users assigned to this role yet.') }}</p>
{% endif %}
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Permissions') }}</h2>
{% set categories = {} %}
{% for permission in role.permissions %}
{% if permission.category not in categories %}
{% set _ = categories.update({permission.category: []}) %}
{% endif %}
{% set _ = categories[permission.category].append(permission) %}
{% endfor %}
{% if categories %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{% for category, perms in categories.items() %}
<div class="border border-border-light dark:border-border-dark rounded-lg p-4">
<h3 class="font-semibold text-primary mb-3 capitalize">{{ category.replace('_', ' ') }}</h3>
<ul class="space-y-2">
{% for permission in perms %}
<li class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<div class="flex-1">
<div class="text-sm font-medium">{{ permission.name.replace('_', ' ').title() }}</div>
{% if permission.description %}
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ permission.description }}</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('This role has no permissions assigned.') }}</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -30,6 +30,23 @@
<input type="checkbox" name="is_active" id="is_active" {% if user.is_active %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="is_active" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Active</label>
</div>
<div class="border border-border-light dark:border-border-dark rounded-lg p-4 bg-bg-secondary-light dark:bg-bg-secondary-dark">
<h3 class="font-semibold mb-2">Advanced Permissions</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
Manage fine-grained role-based permissions for this user.
</p>
<a href="{{ url_for('permissions.manage_user_roles', user_id=user.id) }}" class="inline-block bg-primary text-white px-4 py-2 rounded-lg text-sm hover:bg-opacity-90">
Manage Roles & Permissions
</a>
<div class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark">
{% if user.roles %}
<strong>Current roles:</strong> {{ user.get_role_names()|join(', ') }}
{% else %}
No roles assigned yet. Using legacy role: {{ user.role }}
{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="mt-8 border-t border-border-light dark:border-border-dark pt-6 flex justify-end">

View File

@@ -14,7 +14,7 @@
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-4">Username</th>
<th class="p-4">Role</th>
<th class="p-4">Roles & Permissions</th>
<th class="p-4">Status</th>
<th class="p-4">Actions</th>
</tr>
@@ -22,20 +22,52 @@
<tbody>
{% for user in users %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-4">{{ user.username }}</td>
<td class="p-4">{{ user.role | capitalize }}</td>
<td class="p-4">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ 'bg-green-100 text-green-800' if user.is_active else 'bg-red-100 text-red-800' }}">
<div class="font-medium">{{ user.username }}</div>
{% if user.is_admin %}
<span class="text-xs text-primary">{{ _('Admin Access') }}</span>
{% endif %}
</td>
<td class="p-4">
{% if user.roles %}
<div class="flex flex-wrap gap-1">
{% for role in user.roles %}
<span class="px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full
{% if role.is_system_role %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
{{ role.name }}
</span>
{% endfor %}
</div>
{% else %}
{# Show legacy role if no new roles assigned yet #}
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200">
{{ user.role | capitalize }} (legacy)
</span>
<a href="{{ url_for('permissions.manage_user_roles', user_id=user.id) }}"
class="text-xs text-primary hover:underline"
title="{{ _('Migrate to new role system') }}">
{{ _('Migrate') }} →
</a>
</div>
{% endif %}
</td>
<td class="p-4">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' if user.is_active else 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }}">
{{ 'Active' if user.is_active else 'Inactive' }}
</span>
</td>
<td class="p-4">
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}" class="text-primary hover:underline">Edit</a>
<div class="flex gap-2">
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}" class="text-primary hover:underline text-sm">{{ _('Edit') }}</a>
<a href="{{ url_for('permissions.manage_user_roles', user_id=user.id) }}" class="text-primary hover:underline text-sm">{{ _('Roles') }}</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">No users found.</td>
<td colspan="4" class="p-4 text-center text-text-muted-light dark:text-text-muted-dark">{{ _('No users found.') }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="mb-6">
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}" class="text-primary hover:underline flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
</svg>
{{ _('Back to User') }}
</a>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h1 class="text-2xl font-bold mb-6">{{ _('Manage Roles for') }}: {{ user.username }}</h1>
<form method="post" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<h3 class="text-lg font-semibold mb-4">{{ _('Assign Roles') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Select the roles this user should have. Users inherit all permissions from their assigned roles.') }}
</p>
<div class="space-y-3">
{% for role in all_roles %}
<label class="flex items-start gap-3 p-4 border border-border-light dark:border-border-dark rounded-lg cursor-pointer hover:bg-bg-hover-light dark:hover:bg-bg-hover-dark transition">
<input type="checkbox" name="roles" value="{{ role.id }}"
{% if role in user.roles %}checked{% endif %}
class="mt-1 h-5 w-5 text-primary border-gray-300 rounded focus:ring-primary">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-semibold">{{ role.name }}</span>
{% if role.is_system_role %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ _('System') }}
</span>
{% endif %}
</div>
{% if role.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ role.description }}</p>
{% endif %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ role.permissions|length }} {{ _('permissions') }}
</p>
</div>
</label>
{% endfor %}
</div>
</div>
<div class="flex gap-4">
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-opacity-90 transition">
{{ _('Update Roles') }}
</button>
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}" class="bg-secondary text-white px-6 py-2 rounded-lg hover:bg-opacity-90 transition">
{{ _('Cancel') }}
</a>
</div>
</form>
</div>
<!-- Show current effective permissions -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mt-6">
<h2 class="text-lg font-semibold mb-4">{{ _('Current Effective Permissions') }}</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('These are all the permissions the user currently has through their roles:') }}
</p>
{% set user_permissions = user.get_all_permissions() %}
{% if user_permissions %}
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
{% for permission in user_permissions %}
<div class="text-xs bg-primary/10 text-primary px-2 py-1 rounded">
{{ permission.name.replace('_', ' ').title() }}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No permissions (assign roles to grant permissions)') }}</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -100,7 +100,7 @@
<nav class="flex-1">
{% set ep = request.endpoint or '' %}
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') %}
{% set insights_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('analytics.') %}
{% set insights_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('analytics.') or ep.startswith('expenses.') %}
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider sidebar-label">{{ _('Navigation') }}</h2>
<button id="sidebarCollapseBtn" class="p-1.5 rounded hover:bg-background-light dark:hover:bg-background-dark" aria-label="Toggle sidebar" title="Toggle sidebar">
@@ -114,6 +114,12 @@
<span class="ml-3 sidebar-label">{{ _('Dashboard') }}</span>
</a>
</li>
<li class="mt-2">
<a href="{{ url_for('weekly_goals.index') }}" class="flex items-center p-2 rounded-lg {% if ep.startswith('weekly_goals.') %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-bullseye w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Weekly Goals') }}</span>
</a>
</li>
<li class="mt-2">
<button onclick="toggleDropdown('workDropdown')" data-dropdown="workDropdown" class="w-full flex items-center p-2 rounded-lg {% if work_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-briefcase w-6 text-center"></i>
@@ -156,6 +162,7 @@
<ul id="insightsDropdown" class="{% if not insights_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_reports = ep.startswith('reports.') %}
{% set nav_active_invoices = ep.startswith('invoices.') %}
{% set nav_active_expenses = ep.startswith('expenses.') %}
{% set nav_active_analytics = ep.startswith('analytics.') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a>
@@ -163,18 +170,23 @@
<li>
<a class="block px-2 py-1 rounded {% if nav_active_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoices.list_invoices') }}">{{ _('Invoices') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_expenses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expenses.list_expenses') }}">{{ _('Expenses') }}</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_analytics %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('analytics.analytics_dashboard') }}">{{ _('Analytics') }}</a>
</li>
</ul>
</li>
{% if current_user.is_admin %}
{% if current_user.is_admin or has_any_permission(['view_users', 'manage_settings', 'view_system_info', 'manage_backups']) %}
<li class="mt-2">
<a href="{{ url_for('admin.admin_dashboard') }}" class="flex items-center p-2 rounded-lg {% if ep == 'admin.admin_dashboard' %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-cog w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Admin') }}</span>
</a>
</li>
{% endif %}
{% if current_user.is_admin or has_permission('manage_oidc') %}
<li class="mt-2">
<a href="{{ url_for('admin.oidc_debug') }}" class="flex items-center p-2 rounded-lg {% if ep == 'admin.oidc_debug' %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-shield-alt w-6 text-center"></i>
@@ -290,7 +302,7 @@
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ current_user.email if current_user.is_authenticated else '' }}</div>
</li>
<li><a href="{{ url_for('auth.profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-user w-4"></i> {{ _('Profile') }}</a></li>
<li><a href="{{ url_for('auth.edit_profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('Settings') }}</a></li>
<li><a href="{{ url_for('user.settings') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('Settings') }}</a></li>
<li class="border-t border-border-light dark:border-border-dark"><a href="{{ url_for('auth.logout') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-rose-600 dark:text-rose-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-sign-out-alt w-4"></i> {{ _('Logout') }}</a></li>
</ul>
</div>

View File

@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}{{ _('Edit Client Note') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">
{{ _('Edit Client Note') }}
</h1>
<a href="{{ url_for('clients.view_client', client_id=client_id) }}" class="text-primary hover:underline">
{{ _('Back to Client') }}
</a>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<!-- Note context -->
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
<div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<strong>{{ _('Client:') }}</strong> {{ note.client_name }}
</div>
</div>
</div>
<!-- Original note info -->
<div class="mb-6">
<div class="flex items-center mb-2">
<div class="w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center font-semibold mr-3">
{{ (note.author_name)[0].upper() }}
</div>
<div>
<strong>{{ note.author_name }}</strong>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Created on') }} {{ note.created_at.strftime('%B %d, %Y at %I:%M %p') }}
{% if note.created_at != note.updated_at %}
<br>
{{ _('Last edited on') }} {{ note.updated_at.strftime('%B %d, %Y at %I:%M %p') }}
{% endif %}
</div>
</div>
</div>
</div>
<!-- Edit form -->
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="content" class="block text-sm font-medium mb-2">
{{ _('Note Content') }}
</label>
<textarea
name="content"
id="content"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-background-light dark:bg-background-dark focus:ring-2 focus:ring-primary focus:border-transparent"
rows="8"
required
>{{ note.content }}</textarea>
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Internal note visible only to your team.') }}
</p>
</div>
<div class="mb-6">
<label class="flex items-center">
<input
type="checkbox"
name="is_important"
value="true"
{% if note.is_important %}checked{% endif %}
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary focus:ring-2"
>
<span class="ml-2 text-sm">{{ _('Mark as important') }}</span>
</label>
</div>
<div class="flex gap-3">
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-opacity-90 transition">
{{ _('Save Changes') }}
</button>
<a href="{{ url_for('clients.view_client', client_id=client_id) }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-opacity-90 transition">
{{ _('Cancel') }}
</a>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-resize textarea
const textarea = document.getElementById('content');
function resizeTextarea() {
textarea.style.height = 'auto';
textarea.style.height = (textarea.scrollHeight) + 'px';
}
textarea.addEventListener('input', resizeTextarea);
// Initial resize
resizeTextarea();
// Focus on textarea
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
// Add loading state to form
document.querySelector('form').addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>' + '{{ _('Saving...') }}';
submitBtn.disabled = true;
});
});
</script>
{% endblock %}

View File

@@ -6,7 +6,7 @@
<h1 class="text-2xl font-bold">Clients</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">Manage your clients here.</p>
</div>
{% if current_user.is_admin %}
{% if current_user.is_admin or has_permission('create_clients') %}
<a href="{{ url_for('clients.create_client') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Create Client</a>
{% endif %}
</div>

View File

@@ -7,8 +7,9 @@
<h1 class="text-2xl font-bold">{{ client.name }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">Client details and associated projects.</p>
</div>
{% if current_user.is_admin %}
{% if current_user.is_admin or has_any_permission(['edit_clients', 'delete_clients']) %}
<div class="flex gap-2">
{% if current_user.is_admin or has_permission('edit_clients') %}
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Edit Client') }}</a>
{% if client.status == 'active' %}
<form method="POST" action="{{ url_for('clients.archive_client', client_id=client.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Mark client as Inactive?') }}', { title: '{{ _('Change Client Status') }}', confirmText: '{{ _('Change') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
@@ -21,7 +22,8 @@
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white mt-4 md:mt-0">{{ _('Activate') }}</button>
</form>
{% endif %}
{% if client.total_projects == 0 %}
{% endif %}
{% if (current_user.is_admin or has_permission('delete_clients')) and client.total_projects == 0 %}
<button type="button" class="bg-red-600 text-white px-4 py-2 rounded-lg mt-4 md:mt-0"
onclick="document.getElementById('confirmDeleteClient-{{ client.id }}').classList.remove('hidden')">
{{ _('Delete Client') }}
@@ -108,7 +110,164 @@
</div>
</div>
</div>
{% if current_user.is_admin %}
<!-- Client Notes Section -->
<div class="mt-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">{{ _('Internal Notes') }}</h2>
<button type="button" id="addNoteBtn" class="bg-primary text-white px-4 py-2 rounded-lg text-sm hover:bg-opacity-90 transition">
{{ _('Add Note') }}
</button>
</div>
<!-- Add Note Form (Hidden by default) -->
<div id="addNoteForm" class="hidden mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-border-light dark:border-border-dark">
<form method="POST" action="{{ url_for('client_notes.create_note', client_id=client.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="note-content" class="block text-sm font-medium mb-2">{{ _('Note Content') }}</label>
<textarea
name="content"
id="note-content"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-background-light dark:bg-background-dark focus:ring-2 focus:ring-primary focus:border-transparent"
rows="4"
required
placeholder="{{ _('Add an internal note about this client...') }}"
></textarea>
</div>
<div class="mb-4">
<label class="flex items-center">
<input
type="checkbox"
name="is_important"
value="true"
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary focus:ring-2"
>
<span class="ml-2 text-sm">{{ _('Mark as important') }}</span>
</label>
</div>
<div class="flex gap-2">
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-opacity-90 transition">
{{ _('Save Note') }}
</button>
<button type="button" id="cancelNoteBtn" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg text-sm hover:bg-opacity-90 transition">
{{ _('Cancel') }}
</button>
</div>
</form>
</div>
<!-- Notes List -->
<div id="notesList" class="space-y-4">
{% if client.notes %}
{% for note in client.notes|sort(attribute='created_at', reverse=True) %}
<div class="p-4 bg-background-light dark:bg-background-dark rounded-lg border border-border-light dark:border-border-dark {% if note.is_important %}border-l-4 border-l-amber-500{% endif %}">
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center text-sm font-semibold">
{{ (note.author_name)[0].upper() }}
</div>
<div>
<div class="font-medium">{{ note.author_name }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">
{{ note.created_at.strftime('%B %d, %Y at %I:%M %p') }}
{% if note.created_at != note.updated_at %}
<span class="ml-1">({{ _('edited') }})</span>
{% endif %}
</div>
</div>
{% if note.is_important %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
{{ _('Important') }}
</span>
{% endif %}
</div>
{% if note.can_edit(current_user) %}
<div class="flex gap-2">
<button type="button"
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
onclick="toggleImportant({{ note.id }}, {{ 'true' if not note.is_important else 'false' }})">
{% if note.is_important %}{{ _('Unmark') }}{% else %}{{ _('Mark Important') }}{% endif %}
</button>
<a href="{{ url_for('client_notes.edit_note', client_id=client.id, note_id=note.id) }}"
class="text-sm text-primary hover:underline">
{{ _('Edit') }}
</a>
{% if note.can_delete(current_user) %}
<form method="POST"
action="{{ url_for('client_notes.delete_note', client_id=client.id, note_id=note.id) }}"
class="inline"
onsubmit="return confirm('{{ _('Are you sure you want to delete this note?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-sm text-red-600 dark:text-red-400 hover:underline">
{{ _('Delete') }}
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
<div class="text-sm whitespace-pre-wrap">{{ note.content }}</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-8 text-text-muted-light dark:text-text-muted-dark">
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>{{ _('No notes yet. Add a note to keep track of important information about this client.') }}</p>
</div>
{% endif %}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const addNoteBtn = document.getElementById('addNoteBtn');
const addNoteForm = document.getElementById('addNoteForm');
const cancelNoteBtn = document.getElementById('cancelNoteBtn');
const noteContent = document.getElementById('note-content');
addNoteBtn.addEventListener('click', function() {
addNoteForm.classList.remove('hidden');
noteContent.focus();
});
cancelNoteBtn.addEventListener('click', function() {
addNoteForm.classList.add('hidden');
noteContent.value = '';
});
});
function toggleImportant(noteId, setImportant) {
fetch(`{{ url_for('client_notes.toggle_important', client_id=client.id, note_id=0) }}`.replace('/0/', `/${noteId}/`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('{{ _('Error updating note') }}');
}
})
.catch(error => {
console.error('Error:', error);
alert('{{ _('Error updating note') }}');
});
}
</script>
{% if current_user.is_admin or has_permission('delete_clients') %}
{{ confirm_dialog(
'confirmDeleteClient-' ~ client.id,
'Delete Client',

View File

@@ -0,0 +1,252 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expenses', 'url': url_for('expenses.list_expenses')},
{'text': 'Dashboard'}
] %}
{{ page_header(
icon_class='fas fa-chart-line',
title_text='Expense Dashboard',
subtitle_text='Overview of your expenses',
breadcrumbs=breadcrumbs
) }}
<!-- Date Range Filter -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<form method="GET" class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Date
</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="flex-1 min-w-[200px]">
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Date
</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-search mr-2"></i>Update
</button>
</form>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Expenses</p>
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{{ total_expenses }}</p>
</div>
<div class="text-primary text-4xl">
<i class="fas fa-file-invoice-dollar"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
<p class="text-3xl font-bold text-green-600">{{ '€%.2f'|format(total_amount) }}</p>
</div>
<div class="text-green-500 text-4xl">
<i class="fas fa-euro-sign"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Pending Approval</p>
<p class="text-3xl font-bold text-yellow-600">{{ pending_count }}</p>
</div>
<div class="text-yellow-500 text-4xl">
<i class="fas fa-clock"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Pending Reimbursement</p>
<p class="text-3xl font-bold text-blue-600">{{ pending_reimbursement }}</p>
</div>
<div class="text-blue-500 text-4xl">
<i class="fas fa-money-check-alt"></i>
</div>
</div>
</div>
</div>
<!-- Status & Category Breakdown -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Status Breakdown -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-check-circle mr-2"></i>By Status
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<div class="flex items-center">
<div class="w-3 h-3 rounded-full bg-yellow-500 mr-3"></div>
<span class="text-sm font-medium">Pending</span>
</div>
<span class="font-bold">{{ pending_count }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="flex items-center">
<div class="w-3 h-3 rounded-full bg-green-500 mr-3"></div>
<span class="text-sm font-medium">Approved</span>
</div>
<span class="font-bold">{{ approved_count }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<div class="flex items-center">
<div class="w-3 h-3 rounded-full bg-red-500 mr-3"></div>
<span class="text-sm font-medium">Rejected</span>
</div>
<span class="font-bold">{{ rejected_count }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="flex items-center">
<div class="w-3 h-3 rounded-full bg-blue-500 mr-3"></div>
<span class="text-sm font-medium">Reimbursed</span>
</div>
<span class="font-bold">{{ reimbursed_count }}</span>
</div>
</div>
</div>
<!-- Category Breakdown -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-tags mr-2"></i>By Category
</h3>
<div class="space-y-3">
{% if category_stats %}
{% for stat in category_stats %}
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex items-center">
<i class="fas fa-tag text-primary mr-3"></i>
<span class="text-sm font-medium">{{ stat.category|title }}</span>
</div>
<div class="text-right">
<div class="font-bold">{{ '€%.2f'|format(stat.total_amount) }}</div>
<div class="text-xs text-gray-500">{{ stat.count }} items</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-sm text-gray-500 text-center py-4">No data available</p>
{% endif %}
</div>
</div>
</div>
<!-- Recent Expenses -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold">
<i class="fas fa-history mr-2"></i>Recent Expenses
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Title</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if recent_expenses %}
{% for expense in recent_expenses %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ expense.expense_date.strftime('%Y-%m-%d') }}
</td>
<td class="px-6 py-4 text-sm">
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:underline font-medium">
{{ expense.title }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ expense.category|title }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ expense.currency_code }} {{ '%.2f'|format(expense.total_amount) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if expense.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
{% elif expense.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
{% elif expense.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
{% elif expense.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-receipt text-4xl mb-2 opacity-50"></i>
<p>No expenses found</p>
<a href="{{ url_for('expenses.create_expense') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first expense
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('expenses.list_expenses') }}" class="text-primary hover:underline text-sm">
View all expenses <i class="fas fa-arrow-right ml-1"></i>
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,347 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expenses', 'url': url_for('expenses.list_expenses')},
{'text': 'Edit' if expense else 'New'}
] %}
{{ page_header(
icon_class='fas fa-receipt',
title_text=('Edit Expense' if expense else 'New Expense'),
subtitle_text=('Update expense details' if expense else 'Create a new expense record'),
breadcrumbs=breadcrumbs
) }}
<div class="max-w-4xl mx-auto">
<form method="POST" enctype="multipart/form-data" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Basic Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Title <span class="text-red-500">*</span>
</label>
<input type="text" name="title" id="title" required
value="{{ expense.title if expense else '' }}"
placeholder="e.g., Flight to Berlin"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="md:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea name="description" id="description" rows="3"
placeholder="Additional details about the expense..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ expense.description if expense else '' }}</textarea>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Category <span class="text-red-500">*</span>
</label>
<select name="category" id="category" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">Select Category</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if expense and expense.category == cat %}selected{% endif %}>
{{ cat|title }}
</option>
{% endfor %}
</select>
</div>
<div>
<label for="expense_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Expense Date <span class="text-red-500">*</span>
</label>
<input type="date" name="expense_date" id="expense_date" required
value="{{ expense.expense_date.strftime('%Y-%m-%d') if expense else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Amount Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-euro-sign mr-2"></i>Amount Details
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Amount <span class="text-red-500">*</span>
</label>
<input type="number" name="amount" id="amount" step="0.01" required
value="{{ expense.amount if expense else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="tax_amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tax Amount
</label>
<input type="number" name="tax_amount" id="tax_amount" step="0.01"
value="{{ expense.tax_amount if expense else '0.00' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="currency_code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Currency
</label>
<select name="currency_code" id="currency_code"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="EUR" {% if not expense or expense.currency_code == 'EUR' %}selected{% endif %}>EUR</option>
<option value="USD" {% if expense and expense.currency_code == 'USD' %}selected{% endif %}>USD</option>
<option value="GBP" {% if expense and expense.currency_code == 'GBP' %}selected{% endif %}>GBP</option>
<option value="CHF" {% if expense and expense.currency_code == 'CHF' %}selected{% endif %}>CHF</option>
</select>
</div>
</div>
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-1"></i>
<strong>Total Amount:</strong> <span id="total_display">{{ (expense.amount + (expense.tax_amount or 0)) if expense else '0.00' }}</span> {{ expense.currency_code if expense else 'EUR' }}
</p>
</div>
</div>
<!-- Project & Client -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-project-diagram mr-2"></i>Association
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project
</label>
<select name="project_id" id="project_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Project</option>
{% for project in projects %}
<option value="{{ project.id }}"
data-client-id="{{ project.client_id if project.client_id else '' }}"
{% if expense and expense.project_id == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 mt-1"><i class="fas fa-info-circle"></i> Selecting a project will auto-fill the client</p>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client
</label>
<select name="client_id" id="client_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if expense and expense.client_id == client.id %}selected{% endif %}>
{{ client.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Payment Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-credit-card mr-2"></i>Payment Details
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="payment_method" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Payment Method
</label>
<select name="payment_method" id="payment_method"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">Select Method</option>
{% for method in payment_methods %}
<option value="{{ method }}" {% if expense and expense.payment_method == method %}selected{% endif %}>
{{ method.replace('_', ' ')|title }}
</option>
{% endfor %}
</select>
</div>
<div>
<label for="payment_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Payment Date
</label>
<input type="date" name="payment_date" id="payment_date"
value="{{ expense.payment_date.strftime('%Y-%m-%d') if expense and expense.payment_date else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="vendor" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Vendor
</label>
<input type="text" name="vendor" id="vendor"
value="{{ expense.vendor if expense else '' }}"
placeholder="e.g., Lufthansa"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Receipt & Additional Info -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-file-alt mr-2"></i>Receipt & Additional Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="receipt_file" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Receipt File
</label>
<input type="file" name="receipt_file" id="receipt_file"
accept=".png,.jpg,.jpeg,.gif,.pdf"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<p class="text-xs text-gray-500 mt-1">Allowed: PNG, JPG, GIF, PDF (Max 10MB)</p>
{% if expense and expense.receipt_path %}
<p class="text-xs text-green-600 mt-1">
<i class="fas fa-check-circle"></i> Receipt uploaded
</p>
{% endif %}
</div>
<div>
<label for="receipt_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Receipt/Invoice Number
</label>
<input type="text" name="receipt_number" id="receipt_number"
value="{{ expense.receipt_number if expense else '' }}"
placeholder="e.g., INV-2024-001"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
<div class="mb-4">
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tags (comma-separated)
</label>
<input type="text" name="tags" id="tags"
value="{{ expense.tags if expense else '' }}"
placeholder="e.g., conference, client-meeting, urgent"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes
</label>
<textarea name="notes" id="notes" rows="3"
placeholder="Additional notes..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ expense.notes if expense else '' }}</textarea>
</div>
</div>
<!-- Flags -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-toggle-on mr-2"></i>Options
</h3>
<div class="space-y-3">
<div class="flex items-center">
<input type="checkbox" name="billable" id="billable"
{% if expense and expense.billable %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="billable" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Billable to Client</strong>
<span class="block text-xs text-gray-500">This expense can be billed to the client</span>
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="reimbursable" id="reimbursable"
{% if not expense or expense.reimbursable %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="reimbursable" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Reimbursable</strong>
<span class="block text-xs text-gray-500">Request reimbursement for this expense</span>
</label>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) if expense else url_for('expenses.list_expenses') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ 'Update Expense' if expense else 'Create Expense' }}
</button>
</div>
</form>
</div>
<script>
// Calculate and display total amount
function updateTotal() {
const amount = parseFloat(document.getElementById('amount').value) || 0;
const taxAmount = parseFloat(document.getElementById('tax_amount').value) || 0;
const total = amount + taxAmount;
const currency = document.getElementById('currency_code').value;
document.getElementById('total_display').textContent = total.toFixed(2) + ' ' + currency;
}
document.getElementById('amount').addEventListener('input', updateTotal);
document.getElementById('tax_amount').addEventListener('input', updateTotal);
document.getElementById('currency_code').addEventListener('change', updateTotal);
// Auto-select client when project is selected
document.getElementById('project_id').addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const clientId = selectedOption.getAttribute('data-client-id');
const clientSelect = document.getElementById('client_id');
if (clientId) {
// Set the client dropdown to the project's client
clientSelect.value = clientId;
// Visual feedback
clientSelect.style.transition = 'all 0.3s ease';
clientSelect.style.backgroundColor = '#d1fae5'; // Light green flash
setTimeout(() => {
clientSelect.style.backgroundColor = '';
}, 1000);
} else {
// If project has no client or "No Project" is selected, clear client selection
// But only if user hasn't manually selected a client
// We'll be conservative and not clear it automatically
}
});
// Set default expense date to today if creating new
{% if not expense %}
document.getElementById('expense_date').value = new Date().toISOString().split('T')[0];
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,320 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expenses'}
] %}
{{ page_header(
icon_class='fas fa-receipt',
title_text='Expenses',
subtitle_text='Track and manage business expenses',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("expenses.create_expense") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Expense</a>'
) }}
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Expenses</p>
<p class="text-2xl font-bold">{{ total_count }}</p>
</div>
<div class="text-primary text-3xl">
<i class="fas fa-file-invoice-dollar"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
<p class="text-2xl font-bold">{{ '€%.2f'|format(total_amount) }}</p>
</div>
<div class="text-green-500 text-3xl">
<i class="fas fa-euro-sign"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<a href="{{ url_for('expenses.dashboard') }}" class="text-sm text-primary hover:underline">
View Dashboard <i class="fas fa-arrow-right ml-1"></i>
</a>
</div>
<div class="text-blue-500 text-3xl">
<i class="fas fa-chart-line"></i>
</div>
</div>
</div>
</div>
<!-- Filter Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Expenses</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
<input type="text" name="search" id="search" value="{{ search or '' }}"
placeholder="Title, vendor, notes..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status" id="status" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Statuses</option>
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
<option value="approved" {% if status == 'approved' %}selected{% endif %}>Approved</option>
<option value="rejected" {% if status == 'rejected' %}selected{% endif %}>Rejected</option>
<option value="reimbursed" {% if status == 'reimbursed' %}selected{% endif %}>Reimbursed</option>
</select>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
<select name="category" id="category" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Categories</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if category == cat %}selected{% endif %}>{{ cat|title }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
<select name="project_id" id="project_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Projects</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client</label>
<select name="client_id" id="client_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Clients</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if client_id == client.id %}selected{% endif %}>{{ client.name }}</option>
{% endfor %}
</select>
</div>
{% if current_user.is_admin and users %}
<div>
<label for="user_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User</label>
<select name="user_id" id="user_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user.id }}" {% if user_id == user.id %}selected{% endif %}>{{ user.username }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="billable" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Billable</label>
<select name="billable" id="billable" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All</option>
<option value="true" {% if billable == 'true' %}selected{% endif %}>Billable Only</option>
<option value="false" {% if billable == 'false' %}selected{% endif %}>Non-Billable</option>
</select>
</div>
<div class="flex items-end gap-2">
<button type="submit" class="flex-1 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-filter mr-2"></i>Filter
</button>
<a href="{{ url_for('expenses.list_expenses') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-times"></i>
</a>
<a href="{{ url_for('expenses.export_expenses', **request.args) }}" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors" title="Export to CSV">
<i class="fas fa-download"></i>
</a>
</div>
</form>
</div>
<!-- Expenses Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Title</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Project</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if expenses %}
{% for expense in expenses %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ expense.expense_date.strftime('%Y-%m-%d') }}
</td>
<td class="px-6 py-4 text-sm">
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:underline font-medium">
{{ expense.title }}
</a>
{% if expense.vendor %}
<div class="text-xs text-gray-500">{{ expense.vendor }}</div>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ expense.category|title }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ expense.currency_code }} {{ '%.2f'|format(expense.total_amount) }}
{% if expense.billable %}
<span class="ml-1 text-xs text-green-600" title="Billable"><i class="fas fa-check-circle"></i></span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if expense.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
{% elif expense.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
{% elif expense.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
{% elif expense.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ expense.user.username if expense.user else '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if expense.project %}
<a href="{{ url_for('projects.view_project', project_id=expense.project_id) }}" class="text-primary hover:underline">
{{ expense.project.name }}
</a>
{% else %}
-
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('expenses.view_expense', expense_id=expense.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or expense.user_id == current_user.id %}
<a href="{{ url_for('expenses.edit_expense', expense_id=expense.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="8" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-receipt text-4xl mb-2 opacity-50"></i>
<p>No expenses found</p>
<a href="{{ url_for('expenses.create_expense') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first expense
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination and pagination.pages > 1 %}
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-gray-700 sm:px-6">
<div class="flex-1 flex justify-between sm:hidden">
{% if pagination.has_prev %}
<a href="{{ url_for('expenses.list_expenses', page=pagination.prev_num, **request.args) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Previous
</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('expenses.list_expenses', page=pagination.next_num, **request.args) }}" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
Next
</a>
{% endif %}
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-300">
Showing <span class="font-medium">{{ ((pagination.page - 1) * pagination.per_page) + 1 }}</span>
to <span class="font-medium">{{ min(pagination.page * pagination.per_page, pagination.total) }}</span>
of <span class="font-medium">{{ pagination.total }}</span> results
</p>
</div>
<div>
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('expenses.list_expenses', page=pagination.prev_num, **request.args) }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600">
<i class="fas fa-chevron-left"></i>
</a>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %}
{% if page_num %}
{% if page_num == pagination.page %}
<span class="relative inline-flex items-center px-4 py-2 border border-primary bg-primary text-white text-sm font-medium">
{{ page_num }}
</span>
{% else %}
<a href="{{ url_for('expenses.list_expenses', page=page_num, **request.args) }}" class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600">
{{ page_num }}
</a>
{% endif %}
{% else %}
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300">
...
</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="{{ url_for('expenses.list_expenses', page=pagination.next_num, **request.args) }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-600">
<i class="fas fa-chevron-right"></i>
</a>
{% endif %}
</nav>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,396 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expenses', 'url': url_for('expenses.list_expenses')},
{'text': expense.title}
] %}
{{ page_header(
icon_class='fas fa-receipt',
title_text=expense.title,
subtitle_text='Expense Details',
breadcrumbs=breadcrumbs,
actions_html='<div class="flex gap-2">' +
('<a href="' + url_for("expenses.edit_expense", expense_id=expense.id) + '" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>' if current_user.is_admin or expense.user_id == current_user.id else '') +
'</div>'
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Details -->
<div class="lg:col-span-2 space-y-6">
<!-- Basic Information -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Expense Information
</h3>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Date</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ expense.expense_date.strftime('%Y-%m-%d') }}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Category</dt>
<dd class="mt-1 text-sm">
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ expense.category|title }}
</span>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Amount</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ expense.currency_code }} {{ '%.2f'|format(expense.amount) }}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Tax Amount</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ expense.currency_code }} {{ '%.2f'|format(expense.tax_amount or 0) }}
</dd>
</div>
<div class="md:col-span-2 bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<dt class="text-sm font-medium text-blue-800 dark:text-blue-200">Total Amount (incl. tax)</dt>
<dd class="mt-1 text-xl font-bold text-blue-900 dark:text-blue-100">
{{ expense.currency_code }} {{ '%.2f'|format(expense.total_amount) }}
</dd>
</div>
{% if expense.description %}
<div class="md:col-span-2">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Description</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ expense.description }}
</dd>
</div>
{% endif %}
</dl>
</div>
<!-- Payment Details -->
{% if expense.payment_method or expense.payment_date or expense.vendor or expense.receipt_number %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-credit-card mr-2"></i>Payment Details
</h3>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% if expense.vendor %}
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Vendor</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ expense.vendor }}</dd>
</div>
{% endif %}
{% if expense.payment_method %}
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Payment Method</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ expense.payment_method.replace('_', ' ')|title }}
</dd>
</div>
{% endif %}
{% if expense.payment_date %}
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Payment Date</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ expense.payment_date.strftime('%Y-%m-%d') }}
</dd>
</div>
{% endif %}
{% if expense.receipt_number %}
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Receipt Number</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ expense.receipt_number }}</dd>
</div>
{% endif %}
</dl>
</div>
{% endif %}
<!-- Receipt -->
{% if expense.receipt_path %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-file-alt mr-2"></i>Receipt
</h3>
<div class="flex items-center gap-2">
<i class="fas fa-paperclip text-gray-400"></i>
<a href="{{ url_for('static', filename='../' + expense.receipt_path) }}" target="_blank"
class="text-primary hover:underline">
View Receipt
</a>
</div>
</div>
{% endif %}
<!-- Notes -->
{% if expense.notes %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-sticky-note mr-2"></i>Notes
</h3>
<p class="text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap">{{ expense.notes }}</p>
</div>
{% endif %}
<!-- Approval Actions (for admin) -->
{% if current_user.is_admin and expense.status == 'pending' %}
<div class="bg-yellow-50 dark:bg-yellow-900/20 p-6 rounded-lg border border-yellow-200 dark:border-yellow-800">
<h3 class="text-lg font-semibold mb-4 text-yellow-800 dark:text-yellow-200">
<i class="fas fa-exclamation-triangle mr-2"></i>Pending Approval
</h3>
<div class="flex gap-3">
<form method="POST" action="{{ url_for('expenses.approve_expense', expense_id=expense.id) }}" class="flex-1">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors">
<i class="fas fa-check mr-2"></i>Approve
</button>
</form>
<button onclick="showRejectModal()" class="flex-1 bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
<i class="fas fa-times mr-2"></i>Reject
</button>
</div>
</div>
{% endif %}
<!-- Reimbursement Action (for admin on approved expenses) -->
{% if current_user.is_admin and expense.status == 'approved' and expense.reimbursable and not expense.reimbursed %}
<div class="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<h3 class="text-lg font-semibold mb-4 text-blue-800 dark:text-blue-200">
<i class="fas fa-money-check-alt mr-2"></i>Reimbursement Pending
</h3>
<form method="POST" action="{{ url_for('expenses.mark_reimbursed', expense_id=expense.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-check mr-2"></i>Mark as Reimbursed
</button>
</form>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Status Card -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Status</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Approval Status</span>
{% if expense.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
{% elif expense.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
{% elif expense.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
{% elif expense.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
{% endif %}
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Billable</span>
{% if expense.billable %}
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
{% else %}
<span class="text-gray-400"><i class="fas fa-times-circle"></i> No</span>
{% endif %}
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Reimbursable</span>
{% if expense.reimbursable %}
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
{% else %}
<span class="text-gray-400"><i class="fas fa-times-circle"></i> No</span>
{% endif %}
</div>
{% if expense.invoiced %}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Invoiced</span>
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
</div>
{% endif %}
</div>
{% if expense.rejection_reason %}
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p class="text-sm font-medium text-red-800 dark:text-red-200 mb-1">Rejection Reason:</p>
<p class="text-sm text-red-700 dark:text-red-300">{{ expense.rejection_reason }}</p>
</div>
{% endif %}
</div>
<!-- Association Card -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Associated With</h3>
<div class="space-y-3">
<div>
<span class="text-sm text-gray-600 dark:text-gray-400 block mb-1">User</span>
<span class="text-sm font-medium">{{ expense.user.username if expense.user else '-' }}</span>
</div>
{% if expense.project %}
<div>
<span class="text-sm text-gray-600 dark:text-gray-400 block mb-1">Project</span>
<a href="{{ url_for('projects.view_project', project_id=expense.project_id) }}"
class="text-sm font-medium text-primary hover:underline">
{{ expense.project.name }}
</a>
</div>
{% endif %}
{% if expense.client %}
<div>
<span class="text-sm text-gray-600 dark:text-gray-400 block mb-1">Client</span>
<a href="{{ url_for('clients.view_client', client_id=expense.client_id) }}"
class="text-sm font-medium text-primary hover:underline">
{{ expense.client.name }}
</a>
</div>
{% endif %}
{% if expense.approved_by %}
<div>
<span class="text-sm text-gray-600 dark:text-gray-400 block mb-1">Approved By</span>
<span class="text-sm font-medium">{{ expense.approver.username if expense.approver else '-' }}</span>
{% if expense.approved_at %}
<div class="text-xs text-gray-500">{{ expense.approved_at.strftime('%Y-%m-%d %H:%M') }}</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- Tags -->
{% if expense.tags %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Tags</h3>
<div class="flex flex-wrap gap-2">
{% for tag in expense.tag_list %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{{ tag }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Metadata -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Metadata</h3>
<div class="space-y-2 text-sm">
<div>
<span class="text-gray-600 dark:text-gray-400">Created:</span>
<span class="text-gray-900 dark:text-gray-100">{{ expense.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
<div>
<span class="text-gray-600 dark:text-gray-400">Updated:</span>
<span class="text-gray-900 dark:text-gray-100">{{ expense.updated_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
</div>
<!-- Actions -->
{% if current_user.is_admin or expense.user_id == current_user.id %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4">Actions</h3>
<div class="space-y-2">
<a href="{{ url_for('expenses.edit_expense', expense_id=expense.id) }}"
class="block w-full text-center bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-edit mr-2"></i>Edit Expense
</a>
{% if current_user.is_admin or (expense.user_id == current_user.id and expense.status == 'pending') %}
<form method="POST" action="{{ url_for('expenses.delete_expense', expense_id=expense.id) }}"
onsubmit="return confirm('Are you sure you want to delete this expense?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="block w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
<i class="fas fa-trash mr-2"></i>Delete Expense
</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Rejection Modal -->
<div id="rejectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4">Reject Expense</h3>
<form method="POST" action="{{ url_for('expenses.reject_expense', expense_id=expense.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="rejection_reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Reason for rejection <span class="text-red-500">*</span>
</label>
<textarea name="rejection_reason" id="rejection_reason" rows="4" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700"
placeholder="Explain why this expense is being rejected..."></textarea>
</div>
<div class="flex gap-3">
<button type="button" onclick="hideRejectModal()"
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" class="flex-1 bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
Reject
</button>
</div>
</form>
</div>
</div>
<script>
function showRejectModal() {
document.getElementById('rejectModal').classList.remove('hidden');
}
function hideRejectModal() {
document.getElementById('rejectModal').classList.add('hidden');
}
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
hideRejectModal();
}
});
// Close modal on backdrop click
document.getElementById('rejectModal').addEventListener('click', function(e) {
if (e.target === this) {
hideRejectModal();
}
});
</script>
{% endblock %}

View File

@@ -82,6 +82,25 @@
<td class="num">{{ format_money(item.total_amount) }}</td>
</tr>
{% endfor %}
{% for good in invoice.extra_goods %}
<tr>
<td>
{{ good.name|e }}
{% if good.description %}
<br><small class="good-description">{{ good.description|e }}</small>
{% endif %}
{% if good.sku %}
<br><small class="good-sku">{{ _('SKU') }}: {{ good.sku|e }}</small>
{% endif %}
{% if good.category %}
<br><small class="good-category">{{ _('Category') }}: {{ good.category|title|e }}</small>
{% endif %}
</td>
<td class="num">{{ '%.2f' % good.quantity }}</td>
<td class="num">{{ format_money(good.unit_price) }}</td>
<td class="num">{{ format_money(good.total_amount) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>

View File

@@ -104,6 +104,63 @@
<!-- Right Column: Real Insights -->
<div class="space-y-6">
<!-- Weekly Goal Widget -->
{% if current_week_goal %}
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">
<i class="fas fa-bullseye mr-2"></i>
{{ _('Weekly Goal') }}
</h2>
<a href="{{ url_for('weekly_goals.index') }}" class="text-white hover:text-gray-200 transition">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
<div class="mb-4">
<div class="flex justify-between text-sm mb-2 opacity-90">
<span>{{ current_week_goal.actual_hours }}h / {{ current_week_goal.target_hours }}h</span>
<span>{{ current_week_goal.progress_percentage }}%</span>
</div>
<div class="w-full bg-white bg-opacity-30 rounded-full h-3">
<div class="bg-white rounded-full h-3 transition-all duration-300"
style="width: {{ current_week_goal.progress_percentage }}%"></div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="bg-white bg-opacity-20 rounded p-2">
<div class="opacity-90 text-xs">{{ _('Remaining') }}</div>
<div class="font-semibold">{{ current_week_goal.remaining_hours }}h</div>
</div>
<div class="bg-white bg-opacity-20 rounded p-2">
<div class="opacity-90 text-xs">{{ _('Days Left') }}</div>
<div class="font-semibold">{{ current_week_goal.days_remaining }}</div>
</div>
</div>
{% if current_week_goal.days_remaining > 0 and current_week_goal.remaining_hours > 0 %}
<div class="mt-3 text-sm opacity-90">
<i class="fas fa-info-circle mr-1"></i>
{{ _('Need') }} {{ current_week_goal.average_hours_per_day }}h/day {{ _('to reach goal') }}
</div>
{% endif %}
</div>
{% else %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card border-2 border-dashed border-gray-300 dark:border-gray-600">
<div class="text-center">
<i class="fas fa-bullseye text-4xl text-gray-400 mb-3"></i>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
{{ _('No Weekly Goal') }}
</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">
{{ _('Set a weekly time goal to track your progress') }}
</p>
<a href="{{ url_for('weekly_goals.create') }}"
class="inline-block bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition">
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
</a>
</div>
</div>
{% endif %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<h2 class="text-lg font-semibold mb-4">{{ _('Top Projects (30 days)') }}</h2>
<ul class="space-y-3">

View File

@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Projects', 'url': url_for('projects.list_projects')},
{'text': project.name, 'url': url_for('projects.view_project', project_id=project.id)},
{'text': 'Archive'}
] %}
{{ page_header(
icon_class='fas fa-archive',
title_text='Archive Project',
subtitle_text='Archive "' + project.name + '"',
breadcrumbs=breadcrumbs
) }}
<div class="max-w-2xl mx-auto">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="mb-6">
<div class="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<i class="fas fa-info-circle text-amber-600 dark:text-amber-400 text-xl"></i>
<div class="text-sm text-amber-800 dark:text-amber-200">
<p class="font-medium mb-1">{{ _('What happens when you archive a project?') }}</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>{{ _('The project will be hidden from active project lists') }}</li>
<li>{{ _('No new time entries can be added to this project') }}</li>
<li>{{ _('Existing data and time entries are preserved') }}</li>
<li>{{ _('You can unarchive the project later if needed') }}</li>
</ul>
</div>
</div>
</div>
<form method="POST" action="{{ url_for('projects.archive_project', project_id=project.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-4">
<div>
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Reason for Archiving') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('Optional') }})</span>
</label>
<textarea
id="reason"
name="reason"
rows="4"
class="form-input"
placeholder="{{ _('e.g., Project completed, Client contract ended, Project cancelled, etc.') }}"
></textarea>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Adding a reason helps with project organization and future reference.') }}
</p>
</div>
<!-- Common reasons as quick select buttons -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Quick Select') }}
</label>
<div class="flex flex-wrap gap-2">
<button type="button" onclick="setReason('{{ _('Project completed successfully') }}')" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
{{ _('Project Completed') }}
</button>
<button type="button" onclick="setReason('{{ _('Client contract ended') }}')" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
{{ _('Contract Ended') }}
</button>
<button type="button" onclick="setReason('{{ _('Project cancelled by client') }}')" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
{{ _('Cancelled') }}
</button>
<button type="button" onclick="setReason('{{ _('Project on hold indefinitely') }}')" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
{{ _('On Hold') }}
</button>
<button type="button" onclick="setReason('{{ _('Maintenance period ended') }}')" class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
{{ _('Maintenance Ended') }}
</button>
</div>
</div>
</div>
<div class="flex justify-end gap-3 mt-6 pt-6 border-t border-border-light dark:border-border-dark">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
{{ _('Cancel') }}
</a>
<button type="submit" class="px-4 py-2 rounded-lg bg-amber-600 text-white hover:bg-amber-700 transition-colors">
<i class="fas fa-archive mr-2"></i>{{ _('Archive Project') }}
</button>
</div>
</form>
</div>
</div>
<script>
function setReason(reason) {
document.getElementById('reason').value = reason;
}
</script>
{% endblock %}

View File

@@ -11,7 +11,7 @@
title_text='Projects',
subtitle_text='Manage your projects here',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("projects.create_project") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Project</a>' if current_user.is_admin else None
actions_html='<a href="' + url_for("projects.create_project") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Project</a>' if (current_user.is_admin or has_permission('create_projects')) else None
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
@@ -69,7 +69,7 @@
<ul id="projectsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 z-50 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg max-h-64 overflow-y-auto">
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('archived')"><i class="fas fa-archive mr-2 text-gray-600"></i>Archive</a></li>
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkArchiveDialog()"><i class="fas fa-archive mr-2 text-gray-600"></i>Archive</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
@@ -185,6 +185,7 @@
<form id="bulkStatusChange-form" method="POST" action="{{ url_for('projects.bulk_status_change') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="new_status" id="bulkNewStatus" value="">
<input type="hidden" name="archive_reason" id="bulkArchiveReason" value="">
</form>
<form id="confirmBulkDelete-form" method="POST" action="{{ url_for('projects.bulk_delete_projects') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -249,12 +250,62 @@ function submitBulkDelete(){
});
form.submit();
}
function showBulkArchiveDialog(){
const count = document.querySelectorAll('.project-checkbox:checked').length;
if (count === 0) return false;
// Create a custom modal for archive reason
const modalHtml = `
<div id="bulkArchiveModal" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/50" onclick="closeBulkArchiveModal()"></div>
<div class="relative bg-card-light dark:bg-card-dark rounded-lg shadow-lg max-w-md w-full mx-4 p-6">
<h3 class="text-lg font-semibold mb-4">Archive ${count} Project${count !== 1 ? 's' : ''}?</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Reason for Archiving <span class="text-text-muted-light dark:text-text-muted-dark">(Optional)</span>
</label>
<textarea id="bulkArchiveReasonInput" rows="3" class="form-input" placeholder="e.g., Projects completed, Contracts ended, etc."></textarea>
</div>
<div class="flex gap-2 mb-3">
<button type="button" onclick="setBulkArchiveReason('Projects completed successfully')" class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">Completed</button>
<button type="button" onclick="setBulkArchiveReason('Contracts ended')" class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">Contract Ended</button>
<button type="button" onclick="setBulkArchiveReason('Projects cancelled')" class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">Cancelled</button>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closeBulkArchiveModal()" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</button>
<button type="button" onclick="confirmBulkArchive()" class="px-4 py-2 rounded-lg bg-amber-600 text-white hover:bg-amber-700">Archive</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
return false;
}
function closeBulkArchiveModal(){
const modal = document.getElementById('bulkArchiveModal');
if (modal) modal.remove();
}
function setBulkArchiveReason(reason){
document.getElementById('bulkArchiveReasonInput').value = reason;
}
function confirmBulkArchive(){
const reason = document.getElementById('bulkArchiveReasonInput').value.trim();
document.getElementById('bulkNewStatus').value = 'archived';
document.getElementById('bulkArchiveReason').value = reason;
closeBulkArchiveModal();
submitBulkStatusChange();
}
function showBulkStatusChange(newStatus){
const count = document.querySelectorAll('.project-checkbox:checked').length;
if (count === 0) return false;
const label = {active:'Active', inactive:'Inactive', archived:'Archived'}[newStatus] || newStatus;
const msg = `Are you sure you want to mark ${count} project(s) as ${label}?`;
if (window.showConfirm){ window.showConfirm(msg, { title: 'Change Project Status', confirmText: 'Change' }).then(function(ok){ if (ok){ document.getElementById('bulkNewStatus').value=newStatus; submitBulkStatusChange(); }}); }
if (window.showConfirm){ window.showConfirm(msg, { title: 'Change Project Status', confirmText: 'Change' }).then(function(ok){ if (ok){ document.getElementById('bulkNewStatus').value=newStatus; document.getElementById('bulkArchiveReason').value=''; submitBulkStatusChange(); }}); }
return false;
}
function submitBulkStatusChange(){

View File

@@ -12,33 +12,34 @@
</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.client.name }}</p>
</div>
{% if current_user.is_admin %}
{% if current_user.is_admin or has_any_permission(['edit_projects', 'archive_projects']) %}
<div class="flex gap-2">
{% if current_user.is_admin or has_permission('edit_projects') %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Edit Project') }}</a>
{% endif %}
{% if current_user.is_admin or has_permission('edit_projects') %}
{% if project.status == 'active' %}
<form method="POST" action="{{ url_for('projects.deactivate_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Mark project as Inactive?') }}', { title: '{{ _('Change Project Status') }}', confirmText: '{{ _('Change') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="px-4 py-2 rounded-lg bg-amber-500 text-white mt-4 md:mt-0">{{ _('Mark Inactive') }}</button>
</form>
<form method="POST" action="{{ url_for('projects.archive_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Archive this project?') }}', { title: '{{ _('Archive Project') }}', confirmText: '{{ _('Archive') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="px-4 py-2 rounded-lg bg-gray-600 text-white mt-4 md:mt-0">{{ _('Archive') }}</button>
</form>
{% elif project.status == 'inactive' %}
<form method="POST" action="{{ url_for('projects.activate_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Activate project?') }}', { title: '{{ _('Activate Project') }}', confirmText: '{{ _('Activate') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="px-4 py-2 rounded-lg bg-emerald-600 text-white mt-4 md:mt-0">{{ _('Activate') }}</button>
</form>
<form method="POST" action="{{ url_for('projects.archive_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Archive this project?') }}', { title: '{{ _('Archive Project') }}', confirmText: '{{ _('Archive') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="px-4 py-2 rounded-lg bg-gray-600 text-white mt-4 md:mt-0">{{ _('Archive') }}</button>
</form>
{% endif %}
{% endif %}
{% if current_user.is_admin or has_permission('archive_projects') %}
{% if not project.is_archived %}
<a href="{{ url_for('projects.archive_project', project_id=project.id) }}" class="inline-block px-4 py-2 rounded-lg bg-gray-600 text-white mt-4 md:mt-0 hover:bg-gray-700 transition-colors">{{ _('Archive') }}</a>
{% else %}
<form method="POST" action="{{ url_for('projects.unarchive_project', project_id=project.id) }}" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Unarchive project?') }}', { title: '{{ _('Unarchive Project') }}', confirmText: '{{ _('Unarchive') }}' }).then(ok=>{ if(ok) this.submit(); });" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="px-4 py-2 rounded-lg bg-sky-600 text-white mt-4 md:mt-0">{{ _('Unarchive') }}</button>
</form>
{% endif %}
{% endif %}
<button type="button" class="bg-red-600 text-white px-4 py-2 rounded-lg mt-4 md:mt-0"
onclick="document.getElementById('confirmDeleteProject-{{ project.id }}').classList.remove('hidden')">
{{ _('Delete Project') }}
@@ -84,6 +85,29 @@
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Billing</h3>
<p>{{ 'Billable' if project.billable else 'Not Billable' }} {% if project.hourly_rate %}({{ "%.2f"|format(project.hourly_rate) }}/hr){% endif %}</p>
</div>
{% if project.is_archived and project.archived_at %}
<div class="pt-4 border-t border-border-light dark:border-border-dark">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Archive Information') }}</h3>
<div class="space-y-2 text-sm">
<div>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Archived on:') }}</span>
<span class="font-medium">{{ project.archived_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
{% if project.archived_by_user %}
<div>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Archived by:') }}</span>
<span class="font-medium">{{ project.archived_by_user.full_name or project.archived_by_user.username }}</span>
</div>
{% endif %}
{% if project.archived_reason %}
<div>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Reason:') }}</span>
<p class="mt-1 p-2 bg-gray-50 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-300">{{ project.archived_reason }}</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
@@ -165,7 +189,7 @@
</div>
</div>
</div>
{% if current_user.is_admin %}
{% if current_user.is_admin or has_permission('delete_projects') %}
{{ confirm_dialog(
'confirmDeleteProject-' ~ project.id,
'Delete Project',

View File

@@ -9,7 +9,7 @@
</div>
<form method="POST" class="space-y-8">
{{ csrf_token() if csrf_token }}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- Profile Information -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
@@ -138,6 +138,68 @@
</div>
</div>
<!-- Time Rounding Preferences -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-clock mr-2"></i>{{ _('Time Rounding Preferences') }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ _('Configure how your time entries are rounded. This affects how durations are calculated when you stop timers.') }}
</p>
<div class="space-y-4">
<div class="flex items-center">
<input type="checkbox" id="time_rounding_enabled" name="time_rounding_enabled"
{% if user.time_rounding_enabled %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
onchange="toggleRoundingOptions()">
<label for="time_rounding_enabled" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
<span class="font-medium">{{ _('Enable Time Rounding') }}</span>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ _('Round time entries to configured intervals') }}</p>
</label>
</div>
<div id="rounding-options" class="ml-8 space-y-4 border-l-2 border-gray-200 dark:border-gray-700 pl-4">
<div>
<label for="time_rounding_minutes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Rounding Interval') }}
</label>
<select id="time_rounding_minutes" name="time_rounding_minutes"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
{% for minutes, label in rounding_intervals %}
<option value="{{ minutes }}" {% if user.time_rounding_minutes == minutes %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Time entries will be rounded to this interval') }}</p>
</div>
<div>
<label for="time_rounding_method" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Rounding Method') }}
</label>
<select id="time_rounding_method" name="time_rounding_method"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
onchange="updateRoundingMethodDescription()">
{% for method, label, description in rounding_methods %}
<option value="{{ method }}" data-description="{{ description }}" {% if user.time_rounding_method == method %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p id="rounding-method-description" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></p>
</div>
<!-- Example visualization -->
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-md">
<p class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
<i class="fas fa-info-circle mr-1"></i>{{ _('Example') }}
</p>
<div id="rounding-example" class="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<!-- Will be populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
<!-- Regional Settings -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
@@ -236,6 +298,83 @@ document.getElementById('email_notifications').addEventListener('change', functi
emailField.classList.remove('border-yellow-500');
}
});
// Toggle rounding options visibility
function toggleRoundingOptions() {
const enabled = document.getElementById('time_rounding_enabled').checked;
const options = document.getElementById('rounding-options');
if (enabled) {
options.style.opacity = '1';
options.querySelectorAll('select').forEach(select => select.disabled = false);
} else {
options.style.opacity = '0.5';
options.querySelectorAll('select').forEach(select => select.disabled = true);
}
updateRoundingExample();
}
// Update rounding method description
function updateRoundingMethodDescription() {
const select = document.getElementById('time_rounding_method');
const description = select.options[select.selectedIndex].getAttribute('data-description');
document.getElementById('rounding-method-description').textContent = description;
updateRoundingExample();
}
// Update rounding example visualization
function updateRoundingExample() {
const enabled = document.getElementById('time_rounding_enabled').checked;
const minutes = parseInt(document.getElementById('time_rounding_minutes').value);
const method = document.getElementById('time_rounding_method').value;
const exampleDiv = document.getElementById('rounding-example');
if (!enabled) {
exampleDiv.innerHTML = '<p>{{ _("Time rounding is disabled. All times will be recorded exactly as tracked.") }}</p>';
return;
}
if (minutes === 1) {
exampleDiv.innerHTML = '<p>{{ _("No rounding - times will be recorded exactly as tracked.") }}</p>';
return;
}
// Calculate examples
const testDuration = 62; // 62 minutes = 1h 2min
let rounded;
if (method === 'up') {
rounded = Math.ceil(testDuration / minutes) * minutes;
} else if (method === 'down') {
rounded = Math.floor(testDuration / minutes) * minutes;
} else {
rounded = Math.round(testDuration / minutes) * minutes;
}
const formatTime = (mins) => {
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
if (hours > 0) {
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
}
return `${remainingMins}m`;
};
exampleDiv.innerHTML = `
<p><strong>{{ _("Actual time:") }}</strong> ${formatTime(testDuration)} → <strong>{{ _("Rounded:") }}</strong> ${formatTime(rounded)}</p>
<p class="text-xs opacity-75">{{ _("With ") }}${minutes}{{ _(" minute intervals") }}</p>
`;
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
toggleRoundingOptions();
updateRoundingMethodDescription();
// Update example when settings change
document.getElementById('time_rounding_enabled').addEventListener('change', updateRoundingExample);
document.getElementById('time_rounding_minutes').addEventListener('change', updateRoundingExample);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,137 @@
{% extends "base.html" %}
{% block title %}{{ _('Create Weekly Goal') }} - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-2xl">
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-bullseye mr-2 text-blue-600"></i>
{{ _('Create Weekly Time Goal') }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
{{ _('Set a target for hours to work this week') }}
</p>
</div>
<!-- Form -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<form method="POST" action="{{ url_for('weekly_goals.create') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Target Hours -->
<div class="mb-6">
<label for="target_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Target Hours') }} <span class="text-red-500">*</span>
</label>
<div class="relative">
<input type="number"
id="target_hours"
name="target_hours"
step="0.5"
min="1"
max="168"
required
value="40"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<span class="absolute right-4 top-2 text-gray-500 dark:text-gray-400">hours</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ _('How many hours do you want to work this week?') }}
</p>
</div>
<!-- Week Start Date -->
<div class="mb-6">
<label for="week_start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Week Start Date') }}
</label>
<input type="date"
id="week_start_date"
name="week_start_date"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ _('Leave blank to use current week (starting Monday)') }}
</p>
</div>
<!-- Notes -->
<div class="mb-6">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Notes') }}
</label>
<textarea id="notes"
name="notes"
rows="3"
placeholder="{{ _('Optional notes about your goal...') }}"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
</div>
<!-- Quick Presets -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Quick Presets') }}
</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<button type="button"
onclick="document.getElementById('target_hours').value = 20"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition">
20h
</button>
<button type="button"
onclick="document.getElementById('target_hours').value = 30"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition">
30h
</button>
<button type="button"
onclick="document.getElementById('target_hours').value = 40"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition">
40h
</button>
<button type="button"
onclick="document.getElementById('target_hours').value = 50"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition">
50h
</button>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between space-x-4">
<a href="{{ url_for('weekly_goals.index') }}"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<i class="fas fa-times mr-2"></i> {{ _('Cancel') }}
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
<i class="fas fa-check mr-2"></i> {{ _('Create Goal') }}
</button>
</div>
</form>
</div>
<!-- Tips -->
<div class="mt-6 bg-blue-50 dark:bg-blue-900 border-l-4 border-blue-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-lightbulb text-blue-500"></i>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-900 dark:text-blue-100">
{{ _('Tips for Setting Goals') }}
</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<ul class="list-disc list-inside space-y-1">
<li>{{ _('Be realistic: Consider holidays, meetings, and other commitments') }}</li>
<li>{{ _('Start conservative: You can always adjust your goal later') }}</li>
<li>{{ _('Track progress: Check your dashboard regularly to stay on track') }}</li>
<li>{{ _('Typical full-time: 40 hours per week (8 hours/day, 5 days)') }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block title %}{{ _('Edit Weekly Goal') }} - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-2xl">
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-edit mr-2 text-blue-600"></i>
{{ _('Edit Weekly Time Goal') }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
{{ goal.week_label }}
</p>
</div>
<!-- Form -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<form method="POST" action="{{ url_for('weekly_goals.edit', goal_id=goal.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Week Info (Read-only) -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Week Period') }}</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ goal.week_label }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Current Progress') }}</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">
{{ goal.actual_hours }}h / {{ goal.target_hours }}h ({{ goal.progress_percentage }}%)
</p>
</div>
</div>
</div>
<!-- Target Hours -->
<div class="mb-6">
<label for="target_hours" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Target Hours') }} <span class="text-red-500">*</span>
</label>
<div class="relative">
<input type="number"
id="target_hours"
name="target_hours"
step="0.5"
min="1"
max="168"
required
value="{{ goal.target_hours }}"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<span class="absolute right-4 top-2 text-gray-500 dark:text-gray-400">hours</span>
</div>
</div>
<!-- Status -->
<div class="mb-6">
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Status') }}
</label>
<select id="status"
name="status"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="active" {% if goal.status == 'active' %}selected{% endif %}>{{ _('Active') }}</option>
<option value="completed" {% if goal.status == 'completed' %}selected{% endif %}>{{ _('Completed') }}</option>
<option value="failed" {% if goal.status == 'failed' %}selected{% endif %}>{{ _('Failed') }}</option>
<option value="cancelled" {% if goal.status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
</select>
</div>
<!-- Notes -->
<div class="mb-6">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Notes') }}
</label>
<textarea id="notes"
name="notes"
rows="3"
placeholder="{{ _('Optional notes about your goal...') }}"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white">{{ goal.notes or '' }}</textarea>
</div>
<!-- Actions -->
<div class="flex justify-between">
<div class="space-x-2">
<a href="{{ url_for('weekly_goals.view', goal_id=goal.id) }}"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<i class="fas fa-times mr-2"></i> {{ _('Cancel') }}
</a>
<button type="button"
onclick="event.preventDefault(); window.showConfirm('{{ _('Are you sure you want to delete this goal?') }}', { title: '{{ _('Delete Goal') }}', confirmText: '{{ _('Delete') }}', variant: 'danger' }).then(ok=>{ if(ok) document.getElementById('deleteForm').submit(); });"
class="px-6 py-2 border border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg hover:bg-red-50 dark:hover:bg-red-900 transition">
<i class="fas fa-trash mr-2"></i> {{ _('Delete') }}
</button>
</div>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
<i class="fas fa-save mr-2"></i> {{ _('Save Changes') }}
</button>
</div>
</form>
<!-- Delete Form (Hidden) -->
<form id="deleteForm" method="POST" action="{{ url_for('weekly_goals.delete', goal_id=goal.id) }}" style="display: none;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,229 @@
{% extends "base.html" %}
{% block title %}{{ _('Weekly Time Goals') }} - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-bullseye mr-2 text-blue-600"></i>
{{ _('Weekly Time Goals') }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
{{ _('Set and track your weekly hour targets') }}
</p>
</div>
<a href="{{ url_for('weekly_goals.create') }}" class="btn btn-primary">
<i class="fas fa-plus mr-2"></i> {{ _('New Goal') }}
</a>
</div>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-trophy text-3xl text-yellow-500"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Total Goals') }}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.total_goals }}</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-check-circle text-3xl text-green-500"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Completed') }}</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">{{ stats.completed }}</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-times-circle text-3xl text-red-500"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Failed') }}</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400">{{ stats.failed }}</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-percentage text-3xl text-blue-500"></i>
</div>
<div class="ml-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('Success Rate') }}</p>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">{{ stats.completion_rate }}%</p>
</div>
</div>
</div>
</div>
<!-- Current Week Goal -->
{% if current_goal %}
<div class="bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg shadow-lg p-6 mb-6 text-white">
<h2 class="text-2xl font-bold mb-4">
<i class="fas fa-calendar-week mr-2"></i>
{{ _('Current Week Goal') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<p class="text-sm opacity-90">{{ _('Week') }}</p>
<p class="text-xl font-bold">{{ current_goal.week_label }}</p>
</div>
<div>
<p class="text-sm opacity-90">{{ _('Target Hours') }}</p>
<p class="text-xl font-bold">{{ current_goal.target_hours }}h</p>
</div>
<div>
<p class="text-sm opacity-90">{{ _('Actual Hours') }}</p>
<p class="text-xl font-bold">{{ current_goal.actual_hours }}h</p>
</div>
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-2">
<span>{{ _('Progress') }}</span>
<span>{{ current_goal.progress_percentage }}%</span>
</div>
<div class="w-full bg-white bg-opacity-30 rounded-full h-4">
<div class="bg-white rounded-full h-4 transition-all duration-300"
style="width: {{ current_goal.progress_percentage }}%"></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p class="text-sm opacity-90">{{ _('Remaining Hours') }}</p>
<p class="text-lg font-semibold">{{ current_goal.remaining_hours }}h</p>
</div>
<div>
<p class="text-sm opacity-90">{{ _('Days Remaining') }}</p>
<p class="text-lg font-semibold">{{ current_goal.days_remaining }}</p>
</div>
<div>
<p class="text-sm opacity-90">{{ _('Avg Hours/Day Needed') }}</p>
<p class="text-lg font-semibold">{{ current_goal.average_hours_per_day }}h</p>
</div>
</div>
<div class="mt-4 flex space-x-2">
<a href="{{ url_for('weekly_goals.view', goal_id=current_goal.id) }}"
class="bg-white text-blue-600 px-4 py-2 rounded hover:bg-opacity-90 transition">
<i class="fas fa-eye mr-2"></i> {{ _('View Details') }}
</a>
<a href="{{ url_for('weekly_goals.edit', goal_id=current_goal.id) }}"
class="bg-white bg-opacity-20 text-white px-4 py-2 rounded hover:bg-opacity-30 transition">
<i class="fas fa-edit mr-2"></i> {{ _('Edit Goal') }}
</a>
</div>
</div>
{% else %}
<!-- No Current Goal -->
<div class="bg-yellow-50 dark:bg-yellow-900 border-l-4 border-yellow-400 p-6 mb-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-2xl text-yellow-500"></i>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-yellow-900 dark:text-yellow-100">
{{ _('No goal set for this week') }}
</h3>
<p class="text-yellow-700 dark:text-yellow-300 mt-1">
{{ _('Create a weekly time goal to start tracking your progress') }}
</p>
<a href="{{ url_for('weekly_goals.create') }}"
class="inline-block mt-3 bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600 transition">
<i class="fas fa-plus mr-2"></i> {{ _('Create Goal') }}
</a>
</div>
</div>
</div>
{% endif %}
<!-- Past Goals -->
{% if goals %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-history mr-2 text-gray-600"></i>
{{ _('Goal History') }}
</h2>
<div class="space-y-4">
{% for goal in goals %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-lg transition">
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<div class="flex items-center space-x-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ goal.week_label }}
</h3>
{% if goal.status == 'completed' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<i class="fas fa-check mr-1"></i> {{ _('Completed') }}
</span>
{% elif goal.status == 'active' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
<i class="fas fa-clock mr-1"></i> {{ _('Active') }}
</span>
{% elif goal.status == 'failed' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
<i class="fas fa-times mr-1"></i> {{ _('Failed') }}
</span>
{% endif %}
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ _('Target') }}: {{ goal.target_hours }}h | {{ _('Actual') }}: {{ goal.actual_hours }}h
</p>
</div>
<div class="flex space-x-2">
<a href="{{ url_for('weekly_goals.view', goal_id=goal.id) }}"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400"
title="{{ _('View') }}">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('weekly_goals.edit', goal_id=goal.id) }}"
class="text-gray-600 hover:text-gray-800 dark:text-gray-400"
title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
</div>
</div>
<!-- Progress Bar -->
<div class="mb-2">
<div class="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-1">
<span>{{ _('Progress') }}</span>
<span>{{ goal.progress_percentage }}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
{% if goal.status == 'completed' %}
<div class="bg-green-500 rounded-full h-2" style="width: {{ goal.progress_percentage }}%"></div>
{% elif goal.status == 'failed' %}
<div class="bg-red-500 rounded-full h-2" style="width: {{ goal.progress_percentage }}%"></div>
{% else %}
<div class="bg-blue-500 rounded-full h-2" style="width: {{ goal.progress_percentage }}%"></div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% block title %}{{ _('Weekly Goal Details') }} - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-bullseye mr-2 text-blue-600"></i>
{{ _('Weekly Goal Details') }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
{{ goal.week_label }}
</p>
</div>
<div class="flex space-x-2">
<a href="{{ url_for('weekly_goals.edit', goal_id=goal.id) }}"
class="btn btn-secondary">
<i class="fas fa-edit mr-2"></i> {{ _('Edit') }}
</a>
<a href="{{ url_for('weekly_goals.index') }}"
class="btn btn-secondary">
<i class="fas fa-arrow-left mr-2"></i> {{ _('Back') }}
</a>
</div>
</div>
<!-- Goal Overview -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('Target Hours') }}</h3>
<i class="fas fa-bullseye text-2xl text-blue-500"></i>
</div>
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ goal.target_hours }}h</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('Actual Hours') }}</h3>
<i class="fas fa-clock text-2xl text-green-500"></i>
</div>
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ goal.actual_hours }}h</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('Status') }}</h3>
{% if goal.status == 'completed' %}
<i class="fas fa-check-circle text-2xl text-green-500"></i>
{% elif goal.status == 'active' %}
<i class="fas fa-clock text-2xl text-blue-500"></i>
{% elif goal.status == 'failed' %}
<i class="fas fa-times-circle text-2xl text-red-500"></i>
{% else %}
<i class="fas fa-ban text-2xl text-gray-500"></i>
{% endif %}
</div>
{% if goal.status == 'completed' %}
<p class="text-xl font-bold text-green-600 dark:text-green-400">{{ _('Completed') }}</p>
{% elif goal.status == 'active' %}
<p class="text-xl font-bold text-blue-600 dark:text-blue-400">{{ _('Active') }}</p>
{% elif goal.status == 'failed' %}
<p class="text-xl font-bold text-red-600 dark:text-red-400">{{ _('Failed') }}</p>
{% else %}
<p class="text-xl font-bold text-gray-600 dark:text-gray-400">{{ _('Cancelled') }}</p>
{% endif %}
</div>
</div>
<!-- Progress Card -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-chart-line mr-2"></i>
{{ _('Progress') }}
</h2>
<div class="mb-4">
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
<span>{{ goal.actual_hours }}h / {{ goal.target_hours }}h</span>
<span>{{ goal.progress_percentage }}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
{% if goal.status == 'completed' %}
<div class="bg-green-500 rounded-full h-4 transition-all duration-300"
style="width: {{ goal.progress_percentage }}%"></div>
{% elif goal.status == 'failed' %}
<div class="bg-red-500 rounded-full h-4 transition-all duration-300"
style="width: {{ goal.progress_percentage }}%"></div>
{% else %}
<div class="bg-blue-500 rounded-full h-4 transition-all duration-300"
style="width: {{ goal.progress_percentage }}%"></div>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
<div class="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{{ _('Remaining Hours') }}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ goal.remaining_hours }}h</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{{ _('Days Remaining') }}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ goal.days_remaining }}</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{{ _('Avg Hours/Day Needed') }}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ goal.average_hours_per_day }}h</p>
</div>
</div>
</div>
<!-- Daily Breakdown -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-calendar-alt mr-2"></i>
{{ _('Daily Breakdown') }}
</h2>
<div class="space-y-2">
{% for date, hours in daily_hours.items() %}
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<p class="font-medium text-gray-900 dark:text-white">
{{ date.strftime('%A, %B %d') }}
</p>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ "%.2f"|format(hours) }} hours
</span>
<div class="w-32 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
{% set daily_target = goal.target_hours / 7 %}
{% set daily_percentage = (hours / daily_target * 100) if daily_target > 0 else 0 %}
<div class="bg-blue-500 rounded-full h-2"
style="width: {{ [daily_percentage, 100]|min }}%"></div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Notes -->
{% if goal.notes %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-sticky-note mr-2"></i>
{{ _('Notes') }}
</h2>
<p class="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{{ goal.notes }}</p>
</div>
{% endif %}
<!-- Time Entries -->
{% if time_entries %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-list mr-2"></i>
{{ _('Time Entries This Week') }} ({{ time_entries|length }})
</h2>
<div class="space-y-2">
{% for entry in time_entries %}
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition">
<div class="flex-1">
<div class="flex items-center space-x-2">
<span class="font-medium text-gray-900 dark:text-white">
{{ entry.project.name if entry.project else _('No Project') }}
</span>
{% if entry.task %}
<span class="text-sm text-gray-600 dark:text-gray-400">
• {{ entry.task.name }}
</span>
{% endif %}
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ entry.start_time.strftime('%a, %b %d at %H:%M') }}
{% if entry.notes %}
• {{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}
{% endif %}
</p>
</div>
<div class="text-right">
<p class="font-semibold text-gray-900 dark:text-white">
{{ entry.duration_formatted }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ "%.2f"|format(entry.duration_seconds / 3600) }}h
</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="bg-yellow-50 dark:bg-yellow-900 border-l-4 border-yellow-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
{{ _('No time entries recorded for this week yet') }}
</p>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -6,6 +6,7 @@ from app.models import User, Project, TimeEntry, Settings, Client, RecurringBloc
from datetime import datetime, timedelta
import shutil
from app.utils.backup import create_backup, restore_backup
from app.utils.permissions_seed import seed_all, seed_permissions, seed_roles, migrate_legacy_users
def register_cli_commands(app):
"""Register CLI commands for the application"""
@@ -232,3 +233,32 @@ def register_cli_commands(app):
cur += timedelta(days=1)
db.session.commit()
click.echo(f"Recurring generation complete. Created {created} entries.")
@app.cli.command()
@with_appcontext
def seed_permissions_cmd():
"""Seed default permissions, roles, and migrate existing users
Note: This is now optional! The database migration (flask db upgrade)
automatically seeds permissions and roles. This command is only needed
if you want to re-seed or update permissions after the initial migration.
"""
if seed_all():
click.echo("✓ Permission system initialized successfully")
else:
click.echo("✗ Failed to initialize permission system")
raise SystemExit(1)
@app.cli.command()
@with_appcontext
def update_permissions():
"""Update permissions and roles after system updates
Use this command to add new permissions or update role definitions
without affecting existing user role assignments.
"""
if seed_permissions() and seed_roles():
click.echo("✓ Permissions and roles updated successfully")
else:
click.echo("✗ Failed to update permissions and roles")
raise SystemExit(1)

View File

@@ -1,11 +1,16 @@
from flask import g, request, current_app
from flask_babel import get_locale
from flask_login import current_user
from app.models import Settings
from app.utils.timezone import get_timezone_offset_for_timezone
def register_context_processors(app):
"""Register context processors for the application"""
# Register permission helpers for templates
from app.utils.permissions import init_permission_helpers
init_permission_helpers(app)
@app.context_processor
def inject_settings():
"""Inject settings into all templates"""

View File

@@ -271,8 +271,10 @@ class InvoicePDFGenerator:
return ''
def _generate_items_rows(self):
"""Generate HTML rows for invoice items"""
"""Generate HTML rows for invoice items and extra goods"""
rows = []
# Add regular invoice items
for item in self.invoice.items:
row = f"""
<tr>
@@ -286,6 +288,32 @@ class InvoicePDFGenerator:
</tr>
"""
rows.append(row)
# Add extra goods
for good in self.invoice.extra_goods:
# Build description with category and SKU if available
description_parts = [self._escape(good.name)]
if good.description:
description_parts.append(f"<br><small class='good-description'>{self._escape(good.description)}</small>")
if good.sku:
description_parts.append(f"<br><small class='good-sku'>{_('SKU')}: {self._escape(good.sku)}</small>")
if good.category:
description_parts.append(f"<br><small class='good-category'>{_('Category')}: {self._escape(good.category.title())}</small>")
description_html = ''.join(description_parts)
row = f"""
<tr>
<td>
{description_html}
</td>
<td class="num">{good.quantity:.2f}</td>
<td class="num">{self._format_currency(good.unit_price)}</td>
<td class="num">{self._format_currency(good.total_amount)}</td>
</tr>
"""
rows.append(row)
return ''.join(rows)
def _get_time_entry_info_html(self, item):

View File

@@ -210,7 +210,7 @@ class InvoicePDFGeneratorFallback:
return story
def _build_items_table(self):
"""Build the invoice items table"""
"""Build the invoice items table including extra goods"""
story = []
story.append(Paragraph(_("Invoice Items"), self.styles['SectionHeader']))
@@ -220,6 +220,8 @@ class InvoicePDFGeneratorFallback:
# Table data
data = [headers]
# Add regular invoice items
for item in self.invoice.items:
row = [
item.description,
@@ -229,6 +231,27 @@ class InvoicePDFGeneratorFallback:
]
data.append(row)
# Add extra goods
for good in self.invoice.extra_goods:
# Build description with additional details
description_parts = [good.name]
if good.description:
description_parts.append(f"\n{good.description}")
if good.sku:
description_parts.append(f"\nSKU: {good.sku}")
if good.category:
description_parts.append(f"\nCategory: {good.category.title()}")
description = '\n'.join(description_parts)
row = [
description,
f"{good.quantity:.2f}",
self._format_currency(good.unit_price),
self._format_currency(good.total_amount)
]
data.append(row)
# Add totals
data.append(['', '', _('Subtotal:'), self._format_currency(self.invoice.subtotal)])

177
app/utils/permissions.py Normal file
View File

@@ -0,0 +1,177 @@
"""Utilities for permission checking and decorators"""
from functools import wraps
from flask import flash, redirect, url_for, abort
from flask_login import current_user
from flask_babel import gettext as _
def permission_required(*permissions, require_all=False):
"""
Decorator to require one or more permissions.
Args:
*permissions: Permission name(s) required
require_all: If True, user must have ALL permissions. If False, user needs ANY permission.
Example:
@permission_required('edit_projects')
def edit_project():
...
@permission_required('view_reports', 'export_reports', require_all=True)
def export_report():
...
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash(_('Please log in to access this page'), 'error')
return redirect(url_for('auth.login'))
# Check if user has required permissions
if require_all:
has_access = current_user.has_all_permissions(*permissions)
else:
has_access = current_user.has_any_permission(*permissions)
if not has_access:
flash(_('You do not have permission to access this page'), 'error')
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_or_permission_required(*permissions):
"""
Decorator that allows access if user is an admin OR has any of the specified permissions.
This is useful for gradual migration from admin-only to permission-based access.
Example:
@admin_or_permission_required('delete_projects')
def delete_project():
...
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash(_('Please log in to access this page'), 'error')
return redirect(url_for('auth.login'))
# Allow access if user is admin or has any of the required permissions
if current_user.is_admin or current_user.has_any_permission(*permissions):
return f(*args, **kwargs)
flash(_('You do not have permission to access this page'), 'error')
abort(403)
return decorated_function
return decorator
def check_permission(user, permission_name):
"""
Check if a user has a specific permission.
Args:
user: User object
permission_name: Name of the permission to check
Returns:
bool: True if user has the permission, False otherwise
"""
if not user or not user.is_authenticated:
return False
return user.has_permission(permission_name)
def check_any_permission(user, *permission_names):
"""
Check if a user has any of the specified permissions.
Args:
user: User object
*permission_names: Names of permissions to check
Returns:
bool: True if user has any of the permissions, False otherwise
"""
if not user or not user.is_authenticated:
return False
return user.has_any_permission(*permission_names)
def check_all_permissions(user, *permission_names):
"""
Check if a user has all of the specified permissions.
Args:
user: User object
*permission_names: Names of permissions to check
Returns:
bool: True if user has all of the permissions, False otherwise
"""
if not user or not user.is_authenticated:
return False
return user.has_all_permissions(*permission_names)
def get_user_permissions(user):
"""
Get all permissions for a user.
Args:
user: User object
Returns:
list: List of Permission objects
"""
if not user:
return []
return user.get_all_permissions()
def get_user_permission_names(user):
"""
Get all permission names for a user.
Args:
user: User object
Returns:
list: List of permission name strings
"""
if not user:
return []
permissions = user.get_all_permissions()
return [p.name for p in permissions]
# Template helper functions (register these in app context)
def init_permission_helpers(app):
"""
Initialize permission helper functions for use in templates.
Usage in templates:
{% if has_permission('edit_projects') %}
<button>Edit Project</button>
{% endif %}
"""
@app.context_processor
def inject_permission_helpers():
return {
'has_permission': lambda perm: check_permission(current_user, perm),
'has_any_permission': lambda *perms: check_any_permission(current_user, *perms),
'has_all_permissions': lambda *perms: check_all_permissions(current_user, *perms),
'get_user_permissions': lambda: get_user_permission_names(current_user) if current_user.is_authenticated else [],
}

View File

@@ -0,0 +1,289 @@
"""Utility module for seeding default permissions and roles"""
from app import db
from app.models import Permission, Role, User
from sqlalchemy.exc import IntegrityError
# Define all available permissions organized by category
DEFAULT_PERMISSIONS = [
# Time Entry Permissions
{'name': 'view_own_time_entries', 'description': 'View own time entries', 'category': 'time_entries'},
{'name': 'view_all_time_entries', 'description': 'View all time entries from all users', 'category': 'time_entries'},
{'name': 'create_time_entries', 'description': 'Create time entries', 'category': 'time_entries'},
{'name': 'edit_own_time_entries', 'description': 'Edit own time entries', 'category': 'time_entries'},
{'name': 'edit_all_time_entries', 'description': 'Edit time entries from all users', 'category': 'time_entries'},
{'name': 'delete_own_time_entries', 'description': 'Delete own time entries', 'category': 'time_entries'},
{'name': 'delete_all_time_entries', 'description': 'Delete time entries from all users', 'category': 'time_entries'},
# Project Permissions
{'name': 'view_projects', 'description': 'View projects', 'category': 'projects'},
{'name': 'create_projects', 'description': 'Create new projects', 'category': 'projects'},
{'name': 'edit_projects', 'description': 'Edit project details', 'category': 'projects'},
{'name': 'delete_projects', 'description': 'Delete projects', 'category': 'projects'},
{'name': 'archive_projects', 'description': 'Archive/unarchive projects', 'category': 'projects'},
{'name': 'manage_project_costs', 'description': 'Manage project costs and budgets', 'category': 'projects'},
# Task Permissions
{'name': 'view_own_tasks', 'description': 'View own tasks', 'category': 'tasks'},
{'name': 'view_all_tasks', 'description': 'View all tasks', 'category': 'tasks'},
{'name': 'create_tasks', 'description': 'Create tasks', 'category': 'tasks'},
{'name': 'edit_own_tasks', 'description': 'Edit own tasks', 'category': 'tasks'},
{'name': 'edit_all_tasks', 'description': 'Edit all tasks', 'category': 'tasks'},
{'name': 'delete_own_tasks', 'description': 'Delete own tasks', 'category': 'tasks'},
{'name': 'delete_all_tasks', 'description': 'Delete all tasks', 'category': 'tasks'},
{'name': 'assign_tasks', 'description': 'Assign tasks to users', 'category': 'tasks'},
# Client Permissions
{'name': 'view_clients', 'description': 'View clients', 'category': 'clients'},
{'name': 'create_clients', 'description': 'Create new clients', 'category': 'clients'},
{'name': 'edit_clients', 'description': 'Edit client details', 'category': 'clients'},
{'name': 'delete_clients', 'description': 'Delete clients', 'category': 'clients'},
{'name': 'manage_client_notes', 'description': 'Manage client notes', 'category': 'clients'},
# Invoice Permissions
{'name': 'view_own_invoices', 'description': 'View own invoices', 'category': 'invoices'},
{'name': 'view_all_invoices', 'description': 'View all invoices', 'category': 'invoices'},
{'name': 'create_invoices', 'description': 'Create invoices', 'category': 'invoices'},
{'name': 'edit_invoices', 'description': 'Edit invoices', 'category': 'invoices'},
{'name': 'delete_invoices', 'description': 'Delete invoices', 'category': 'invoices'},
{'name': 'send_invoices', 'description': 'Send invoices to clients', 'category': 'invoices'},
{'name': 'manage_payments', 'description': 'Manage invoice payments', 'category': 'invoices'},
# Report Permissions
{'name': 'view_own_reports', 'description': 'View own reports', 'category': 'reports'},
{'name': 'view_all_reports', 'description': 'View reports for all users', 'category': 'reports'},
{'name': 'export_reports', 'description': 'Export reports to CSV/PDF', 'category': 'reports'},
{'name': 'create_saved_reports', 'description': 'Create and save custom reports', 'category': 'reports'},
# User Management Permissions
{'name': 'view_users', 'description': 'View users list', 'category': 'users'},
{'name': 'create_users', 'description': 'Create new users', 'category': 'users'},
{'name': 'edit_users', 'description': 'Edit user details', 'category': 'users'},
{'name': 'delete_users', 'description': 'Delete users', 'category': 'users'},
{'name': 'manage_user_roles', 'description': 'Assign roles to users', 'category': 'users'},
# System Permissions
{'name': 'manage_settings', 'description': 'Manage system settings', 'category': 'system'},
{'name': 'view_system_info', 'description': 'View system information', 'category': 'system'},
{'name': 'manage_backups', 'description': 'Create and restore backups', 'category': 'system'},
{'name': 'manage_telemetry', 'description': 'Manage telemetry settings', 'category': 'system'},
{'name': 'view_audit_logs', 'description': 'View audit logs', 'category': 'system'},
# Role & Permission Management (Super Admin only)
{'name': 'manage_roles', 'description': 'Create, edit, and delete roles', 'category': 'administration'},
{'name': 'manage_permissions', 'description': 'Assign permissions to roles', 'category': 'administration'},
{'name': 'view_permissions', 'description': 'View permissions and roles', 'category': 'administration'},
]
# Define default roles with their permissions
DEFAULT_ROLES = {
'super_admin': {
'description': 'Super Administrator with full system access',
'is_system_role': True,
'permissions': [p['name'] for p in DEFAULT_PERMISSIONS] # All permissions
},
'admin': {
'description': 'Administrator with most privileges',
'is_system_role': True,
'permissions': [
# Time entries
'view_all_time_entries', 'create_time_entries', 'edit_all_time_entries', 'delete_all_time_entries',
# Projects
'view_projects', 'create_projects', 'edit_projects', 'delete_projects', 'archive_projects', 'manage_project_costs',
# Tasks
'view_all_tasks', 'create_tasks', 'edit_all_tasks', 'delete_all_tasks', 'assign_tasks',
# Clients
'view_clients', 'create_clients', 'edit_clients', 'delete_clients', 'manage_client_notes',
# Invoices
'view_all_invoices', 'create_invoices', 'edit_invoices', 'delete_invoices', 'send_invoices', 'manage_payments',
# Reports
'view_all_reports', 'export_reports', 'create_saved_reports',
# Users
'view_users', 'create_users', 'edit_users', 'delete_users',
# System
'manage_settings', 'view_system_info', 'manage_backups', 'manage_telemetry', 'view_audit_logs',
]
},
'manager': {
'description': 'Team Manager with oversight capabilities',
'is_system_role': True,
'permissions': [
# Time entries
'view_all_time_entries', 'create_time_entries', 'edit_own_time_entries', 'delete_own_time_entries',
# Projects
'view_projects', 'create_projects', 'edit_projects', 'manage_project_costs',
# Tasks
'view_all_tasks', 'create_tasks', 'edit_all_tasks', 'assign_tasks',
# Clients
'view_clients', 'create_clients', 'edit_clients', 'manage_client_notes',
# Invoices
'view_all_invoices', 'create_invoices', 'edit_invoices', 'send_invoices',
# Reports
'view_all_reports', 'export_reports', 'create_saved_reports',
# Users
'view_users',
]
},
'user': {
'description': 'Standard User',
'is_system_role': True,
'permissions': [
# Time entries
'view_own_time_entries', 'create_time_entries', 'edit_own_time_entries', 'delete_own_time_entries',
# Projects
'view_projects',
# Tasks
'view_own_tasks', 'create_tasks', 'edit_own_tasks', 'delete_own_tasks',
# Clients
'view_clients',
# Invoices
'view_own_invoices',
# Reports
'view_own_reports', 'export_reports',
]
},
'viewer': {
'description': 'Read-only User',
'is_system_role': True,
'permissions': [
'view_own_time_entries',
'view_projects',
'view_own_tasks',
'view_clients',
'view_own_invoices',
'view_own_reports',
]
}
}
def seed_permissions():
"""Seed default permissions into the database"""
print("Seeding permissions...")
created_count = 0
existing_count = 0
for perm_data in DEFAULT_PERMISSIONS:
# Check if permission already exists
existing = Permission.query.filter_by(name=perm_data['name']).first()
if existing:
existing_count += 1
# Update description if it changed
if existing.description != perm_data['description']:
existing.description = perm_data['description']
existing.category = perm_data['category']
continue
# Create new permission
permission = Permission(
name=perm_data['name'],
description=perm_data['description'],
category=perm_data['category']
)
db.session.add(permission)
created_count += 1
try:
db.session.commit()
print(f"Permissions seeded: {created_count} created, {existing_count} already existed")
return True
except IntegrityError as e:
db.session.rollback()
print(f"Error seeding permissions: {e}")
return False
def seed_roles():
"""Seed default roles with their permissions"""
print("Seeding roles...")
created_count = 0
existing_count = 0
for role_name, role_data in DEFAULT_ROLES.items():
# Check if role already exists
existing = Role.query.filter_by(name=role_name).first()
if existing:
existing_count += 1
# Update description if it changed
if existing.description != role_data['description']:
existing.description = role_data['description']
role = existing
else:
# Create new role
role = Role(
name=role_name,
description=role_data['description'],
is_system_role=role_data['is_system_role']
)
db.session.add(role)
created_count += 1
# Assign permissions to role
for perm_name in role_data['permissions']:
permission = Permission.query.filter_by(name=perm_name).first()
if permission and not role.has_permission(perm_name):
role.add_permission(permission)
try:
db.session.commit()
print(f"Roles seeded: {created_count} created, {existing_count} already existed")
return True
except IntegrityError as e:
db.session.rollback()
print(f"Error seeding roles: {e}")
return False
def migrate_legacy_users():
"""Migrate users with legacy 'role' field to new role system"""
print("Migrating legacy users to new role system...")
migrated_count = 0
# Get all users
users = User.query.all()
for user in users:
# Skip if user already has roles assigned
if len(user.roles) > 0:
continue
# Map legacy role to new role system
if user.role == 'admin':
admin_role = Role.query.filter_by(name='admin').first()
if admin_role:
user.add_role(admin_role)
migrated_count += 1
else: # user.role == 'user' or any other value
user_role = Role.query.filter_by(name='user').first()
if user_role:
user.add_role(user_role)
migrated_count += 1
try:
db.session.commit()
print(f"Migrated {migrated_count} users to new role system")
return True
except Exception as e:
db.session.rollback()
print(f"Error migrating users: {e}")
return False
def seed_all():
"""Seed all permissions, roles, and migrate users"""
print("Starting permission system seeding...")
if not seed_permissions():
return False
if not seed_roles():
return False
if not migrate_legacy_users():
return False
print("Permission system seeding completed successfully!")
return True

153
app/utils/time_rounding.py Normal file
View File

@@ -0,0 +1,153 @@
"""Time rounding utilities for per-user time entry rounding preferences"""
import math
from typing import Optional
def round_time_duration(
duration_seconds: int,
rounding_minutes: int = 1,
rounding_method: str = 'nearest'
) -> int:
"""
Round a time duration in seconds based on the specified rounding settings.
Args:
duration_seconds: The raw duration in seconds
rounding_minutes: The rounding interval in minutes (e.g., 1, 5, 10, 15, 30, 60)
rounding_method: The rounding method ('nearest', 'up', or 'down')
Returns:
int: The rounded duration in seconds
Examples:
>>> round_time_duration(3720, 15, 'nearest') # 62 minutes -> 60 minutes (1 hour)
3600
>>> round_time_duration(3720, 15, 'up') # 62 minutes -> 75 minutes (1.25 hours)
4500
>>> round_time_duration(3720, 15, 'down') # 62 minutes -> 60 minutes (1 hour)
3600
"""
# If rounding is disabled (rounding_minutes = 1), return raw duration
if rounding_minutes <= 1:
return duration_seconds
# Validate rounding method
if rounding_method not in ('nearest', 'up', 'down'):
rounding_method = 'nearest'
# Convert to minutes for easier calculation
duration_minutes = duration_seconds / 60.0
# Apply rounding based on method
if rounding_method == 'up':
rounded_minutes = math.ceil(duration_minutes / rounding_minutes) * rounding_minutes
elif rounding_method == 'down':
rounded_minutes = math.floor(duration_minutes / rounding_minutes) * rounding_minutes
else: # 'nearest'
rounded_minutes = round(duration_minutes / rounding_minutes) * rounding_minutes
# Convert back to seconds
return int(rounded_minutes * 60)
def get_user_rounding_settings(user) -> dict:
"""
Get the time rounding settings for a user.
Args:
user: A User model instance
Returns:
dict: Dictionary with 'enabled', 'minutes', and 'method' keys
"""
return {
'enabled': getattr(user, 'time_rounding_enabled', True),
'minutes': getattr(user, 'time_rounding_minutes', 1),
'method': getattr(user, 'time_rounding_method', 'nearest')
}
def apply_user_rounding(duration_seconds: int, user) -> int:
"""
Apply a user's rounding preferences to a duration.
Args:
duration_seconds: The raw duration in seconds
user: A User model instance with rounding preferences
Returns:
int: The rounded duration in seconds
"""
settings = get_user_rounding_settings(user)
# If rounding is disabled for this user, return raw duration
if not settings['enabled']:
return duration_seconds
return round_time_duration(
duration_seconds,
settings['minutes'],
settings['method']
)
def format_rounding_interval(minutes: int) -> str:
"""
Format a rounding interval in minutes as a human-readable string.
Args:
minutes: The rounding interval in minutes
Returns:
str: A human-readable description
Examples:
>>> format_rounding_interval(1)
'No rounding (exact time)'
>>> format_rounding_interval(15)
'15 minutes'
>>> format_rounding_interval(60)
'1 hour'
"""
if minutes <= 1:
return 'No rounding (exact time)'
elif minutes == 60:
return '1 hour'
elif minutes >= 60:
hours = minutes // 60
return f'{hours} hour{"s" if hours > 1 else ""}'
else:
return f'{minutes} minute{"s" if minutes > 1 else ""}'
def get_available_rounding_intervals() -> list:
"""
Get the list of available rounding intervals.
Returns:
list: List of tuples (minutes, label)
"""
return [
(1, 'No rounding (exact time)'),
(5, '5 minutes'),
(10, '10 minutes'),
(15, '15 minutes'),
(30, '30 minutes'),
(60, '1 hour')
]
def get_available_rounding_methods() -> list:
"""
Get the list of available rounding methods.
Returns:
list: List of tuples (method, label, description)
"""
return [
('nearest', 'Round to nearest', 'Round to the nearest interval (standard rounding)'),
('up', 'Always round up', 'Always round up to the next interval (ceiling)'),
('down', 'Always round down', 'Always round down to the previous interval (floor)')
]

105
apply_migration.py Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Simple script to apply the time rounding preferences migration
"""
import os
import sys
# Add the project root to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import create_app, db
from sqlalchemy import inspect, text
def check_columns_exist():
"""Check if the time rounding columns already exist"""
app = create_app()
with app.app_context():
inspector = inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('users')]
has_enabled = 'time_rounding_enabled' in columns
has_minutes = 'time_rounding_minutes' in columns
has_method = 'time_rounding_method' in columns
return has_enabled, has_minutes, has_method
def apply_migration():
"""Apply the migration to add time rounding columns"""
app = create_app()
with app.app_context():
print("Applying time rounding preferences migration...")
# Check if columns already exist
has_enabled, has_minutes, has_method = check_columns_exist()
if has_enabled and has_minutes and has_method:
print("✓ Migration already applied! All columns exist.")
return True
# Apply the migration
try:
if not has_enabled:
print("Adding time_rounding_enabled column...")
db.session.execute(text(
"ALTER TABLE users ADD COLUMN time_rounding_enabled BOOLEAN DEFAULT 1 NOT NULL"
))
if not has_minutes:
print("Adding time_rounding_minutes column...")
db.session.execute(text(
"ALTER TABLE users ADD COLUMN time_rounding_minutes INTEGER DEFAULT 1 NOT NULL"
))
if not has_method:
print("Adding time_rounding_method column...")
db.session.execute(text(
"ALTER TABLE users ADD COLUMN time_rounding_method VARCHAR(10) DEFAULT 'nearest' NOT NULL"
))
db.session.commit()
print("✓ Migration applied successfully!")
# Verify
has_enabled, has_minutes, has_method = check_columns_exist()
if has_enabled and has_minutes and has_method:
print("✓ Verification passed! All columns exist.")
return True
else:
print("✗ Verification failed! Some columns are missing.")
return False
except Exception as e:
print(f"✗ Migration failed: {e}")
db.session.rollback()
return False
if __name__ == '__main__':
print("=== Time Rounding Preferences Migration ===")
print()
# Check current state
try:
has_enabled, has_minutes, has_method = check_columns_exist()
print("Current database state:")
print(f" - time_rounding_enabled: {'✓ exists' if has_enabled else '✗ missing'}")
print(f" - time_rounding_minutes: {'✓ exists' if has_minutes else '✗ missing'}")
print(f" - time_rounding_method: {'✓ exists' if has_method else '✗ missing'}")
print()
except Exception as e:
print(f"✗ Could not check database state: {e}")
sys.exit(1)
# Apply migration if needed
if has_enabled and has_minutes and has_method:
print("All columns already exist. No migration needed.")
else:
success = apply_migration()
if success:
print("\n✓ Migration complete! You can now use the time rounding preferences feature.")
print(" Please restart your application to load the changes.")
else:
print("\n✗ Migration failed. Please check the error messages above.")
sys.exit(1)

View File

@@ -0,0 +1,460 @@
# Advanced Permission Handling System
## Overview
TimeTracker now includes a comprehensive, role-based permission system that allows administrators to control access to various features and functionality at a granular level. This system replaces the simple "admin" vs "user" model with a flexible role-based access control (RBAC) system.
## Key Concepts
### Permissions
**Permissions** are individual capabilities or actions that a user can perform in the system. Examples include:
- `view_all_time_entries` - View time entries from all users
- `create_projects` - Create new projects
- `edit_invoices` - Edit invoice details
- `manage_settings` - Access and modify system settings
Each permission has:
- **Name**: A unique identifier (e.g., `edit_projects`)
- **Description**: Human-readable explanation of what the permission allows
- **Category**: Logical grouping (e.g., `projects`, `invoices`, `system`)
### Roles
**Roles** are collections of permissions that can be assigned to users. Instead of granting individual permissions to each user, you assign roles that bundle related permissions together.
Examples of roles:
- **Super Admin**: Full system access with all permissions
- **Admin**: Most administrative capabilities except role management
- **Manager**: Can oversee projects, tasks, and team members
- **User**: Standard access for time tracking and personal data
- **Viewer**: Read-only access
Each role has:
- **Name**: Unique identifier
- **Description**: Explanation of the role's purpose
- **System Role Flag**: Indicates whether the role is built-in (cannot be deleted)
- **Permissions**: Collection of permissions assigned to the role
### Users and Roles
Users can be assigned one or more roles. A user's effective permissions are the union of all permissions from their assigned roles.
## Default Roles and Permissions
### System Roles
The following system roles are created by default:
#### Super Admin
- **All permissions** in the system
- Can manage roles and permissions themselves
- Intended for system administrators
#### Admin
- All permissions except role/permission management
- Can manage users, projects, invoices, settings
- Cannot modify the permission system itself
#### Manager
- Oversight capabilities for teams and projects
- Can view all time entries and reports
- Can create and edit projects, tasks, and clients
- Can create and send invoices
- Cannot delete users or modify system settings
#### User
- Standard time tracking capabilities
- Can create and edit own time entries
- Can create and manage own tasks
- View-only access to projects and clients
- Can view own reports and invoices
#### Viewer
- Read-only access
- Can view own time entries, tasks, and reports
- Cannot create or modify anything
### Permission Categories
Permissions are organized into the following categories:
#### Time Entries
- `view_own_time_entries`
- `view_all_time_entries`
- `create_time_entries`
- `edit_own_time_entries`
- `edit_all_time_entries`
- `delete_own_time_entries`
- `delete_all_time_entries`
#### Projects
- `view_projects`
- `create_projects`
- `edit_projects`
- `delete_projects`
- `archive_projects`
- `manage_project_costs`
#### Tasks
- `view_own_tasks`
- `view_all_tasks`
- `create_tasks`
- `edit_own_tasks`
- `edit_all_tasks`
- `delete_own_tasks`
- `delete_all_tasks`
- `assign_tasks`
#### Clients
- `view_clients`
- `create_clients`
- `edit_clients`
- `delete_clients`
- `manage_client_notes`
#### Invoices
- `view_own_invoices`
- `view_all_invoices`
- `create_invoices`
- `edit_invoices`
- `delete_invoices`
- `send_invoices`
- `manage_payments`
#### Reports
- `view_own_reports`
- `view_all_reports`
- `export_reports`
- `create_saved_reports`
#### User Management
- `view_users`
- `create_users`
- `edit_users`
- `delete_users`
- `manage_user_roles`
#### System
- `manage_settings`
- `view_system_info`
- `manage_backups`
- `manage_telemetry`
- `view_audit_logs`
#### Administration (Super Admin Only)
- `manage_roles`
- `manage_permissions`
- `view_permissions`
## Using the Permission System
### For Administrators
#### Viewing Roles
1. Navigate to **Admin Dashboard****Roles & Permissions**
2. View all available roles with their permission counts
3. Click on a role to see detailed information and assigned users
#### Creating Custom Roles
1. Go to **Admin Dashboard****Roles & Permissions**
2. Click **Create Role**
3. Enter:
- Role name (e.g., "Project Manager")
- Description (optional)
4. Select permissions by category
5. Click **Create Role**
**Note**: Custom roles can be modified or deleted. System roles cannot be deleted but serve as templates for custom roles.
#### Editing Roles
1. Navigate to the role list
2. Click **Edit** on a custom role (system roles cannot be edited)
3. Modify name, description, or permissions
4. Click **Update Role**
#### Assigning Roles to Users
1. Go to **Admin Dashboard****Manage Users**
2. Click **Edit** on a user
3. Click **Manage Roles & Permissions**
4. Select the roles to assign
5. Click **Update Roles**
Users can have multiple roles. Their effective permissions will be the combination of all assigned roles.
#### Viewing User Permissions
1. Edit a user in the admin panel
2. Click **Manage Roles & Permissions**
3. Scroll to "Current Effective Permissions" to see all permissions the user has
### For Developers
#### Checking Permissions in Code
Use the permission checking methods on the User model:
```python
from flask_login import current_user
# Check single permission
if current_user.has_permission('edit_projects'):
# Allow editing
# Check if user has ANY of the permissions
if current_user.has_any_permission('edit_projects', 'delete_projects'):
# Allow action
# Check if user has ALL of the permissions
if current_user.has_all_permissions('create_invoices', 'send_invoices'):
# Allow action
```
#### Using Permission Decorators
Protect routes with permission decorators:
```python
from app.utils.permissions import permission_required
@app.route('/projects/<id>/edit')
@login_required
@permission_required('edit_projects')
def edit_project(id):
# Only users with edit_projects permission can access
pass
# Require multiple permissions (user needs ANY of them)
@app.route('/reports/export')
@login_required
@permission_required('view_all_reports', 'export_reports')
def export_report():
pass
# Require ALL permissions
@app.route('/admin/critical')
@login_required
@permission_required('manage_settings', 'manage_backups', require_all=True)
def critical_admin_action():
pass
```
#### Admin or Permission Required
For gradual migration, use the `admin_or_permission_required` decorator:
```python
from app.utils.permissions import admin_or_permission_required
@app.route('/projects/delete')
@login_required
@admin_or_permission_required('delete_projects')
def delete_project():
# Admins OR users with delete_projects permission can access
pass
```
#### Checking Permissions in Templates
Use the template helpers to conditionally show UI elements:
```html
{% if has_permission('edit_projects') %}
<a href="{{ url_for('projects.edit', id=project.id) }}">Edit Project</a>
{% endif %}
{% if has_any_permission('create_invoices', 'edit_invoices') %}
<button>Manage Invoices</button>
{% endif %}
{% if has_all_permissions('view_all_reports', 'export_reports') %}
<a href="{{ url_for('reports.export') }}">Export All Reports</a>
{% endif %}
```
## Migration from Legacy System
### Backward Compatibility
The new permission system is fully backward compatible with the existing "role" field:
- Users with `role='admin'` are automatically recognized as administrators
- Legacy admin users have all permissions (even without assigned roles)
- The `is_admin` property checks both the legacy role field and new role assignments
### Migrating Existing Users
To migrate users to the new system:
1. Run the migration command:
```bash
flask seed_permissions_cmd
```
2. This will:
- Create all default permissions
- Create all default roles
- Migrate existing users:
- Users with `role='admin'` get the "admin" role
- Users with `role='user'` get the "user" role
3. Optionally, review and adjust role assignments in the admin panel
### Updating Permissions After Updates
If new permissions are added in a system update:
```bash
flask update_permissions
```
This command updates permissions and roles without affecting user assignments.
## Database Schema
### Tables
#### `permissions`
- `id` - Primary key
- `name` - Unique permission identifier
- `description` - Human-readable description
- `category` - Permission category
- `created_at` - Timestamp
#### `roles`
- `id` - Primary key
- `name` - Unique role identifier
- `description` - Role description
- `is_system_role` - Boolean flag
- `created_at` - Creation timestamp
- `updated_at` - Last update timestamp
#### `role_permissions` (Association Table)
- `role_id` - Foreign key to roles
- `permission_id` - Foreign key to permissions
- `created_at` - Assignment timestamp
#### `user_roles` (Association Table)
- `user_id` - Foreign key to users
- `role_id` - Foreign key to roles
- `assigned_at` - Assignment timestamp
## API Endpoints
### Get User Permissions
```
GET /api/users/<user_id>/permissions
```
Returns:
```json
{
"user_id": 1,
"username": "john",
"roles": [
{"id": 1, "name": "manager"}
],
"permissions": [
{"id": 1, "name": "view_all_time_entries", "description": "..."},
{"id": 2, "name": "create_projects", "description": "..."}
]
}
```
### Get Role Permissions
```
GET /api/roles/<role_id>/permissions
```
Returns:
```json
{
"role_id": 1,
"name": "manager",
"description": "Team Manager with oversight capabilities",
"is_system_role": true,
"permissions": [
{"id": 1, "name": "view_all_time_entries", "category": "time_entries", "description": "..."}
]
}
```
## Best Practices
### Creating Custom Roles
1. **Start with a system role**: Use system roles as templates
2. **Be specific**: Create roles for specific job functions (e.g., "Invoice Manager", "Project Lead")
3. **Least privilege**: Grant only the permissions needed for the role's purpose
4. **Document**: Add clear descriptions to custom roles
### Permission Naming
- Use snake_case: `create_projects`, not `CreateProjects`
- Action first: `edit_invoices`, not `invoices_edit`
- Be specific: `view_all_time_entries` vs `view_own_time_entries`
### Testing Permissions
Always test permission changes:
1. Create a test user
2. Assign the role
3. Log in as that user
4. Verify they can/cannot access expected features
## Troubleshooting
### User Cannot Access Feature
1. Check user's assigned roles
2. Verify the roles have the required permission
3. Check if the feature requires multiple permissions
4. Ensure user account is active
### Cannot Edit/Delete Role
- System roles cannot be edited or deleted
- Roles assigned to users cannot be deleted (reassign users first)
### Legacy Admin Lost Permissions
If a legacy admin user (with `role='admin'`) loses permissions:
1. Verify their `role` field is still 'admin'
2. If using new role system, assign them the "super_admin" or "admin" role
3. The system checks both legacy role and new roles
### Permission Changes Not Taking Effect
- Log out and log back in
- Permissions are loaded on login
- Check browser cache/session
## Security Considerations
- **Super Admin Role**: Assign sparingly - it has full system access
- **Regular Audits**: Review user role assignments periodically
- **Separation of Duties**: Don't assign conflicting roles (e.g., invoice creation + approval)
- **Testing**: Always test in a non-production environment first
## Future Enhancements
Planned features:
- **Permission inheritance**: Hierarchical permissions
- **Time-based roles**: Temporary role assignments
- **Audit logging**: Track permission changes
- **Role templates**: Exportable role configurations
- **API keys with permissions**: Scoped API access
## Support
For issues or questions about the permission system:
1. Check this documentation
2. Review the test files: `tests/test_permissions.py` and `tests/test_permissions_routes.py`
3. Check the implementation: `app/models/permission.py` and `app/utils/permissions.py`

View File

@@ -0,0 +1,415 @@
# Client Notes Feature
## Overview
The **Client Notes** feature allows you to add internal notes about your clients. These notes are completely private and only visible to your team, not to clients. This is perfect for tracking important client information, preferences, special requirements, or any other internal details you need to remember.
---
## Key Features
### 📝 Internal Note Taking
- Add unlimited notes to any client
- Notes are completely internal and never visible to clients
- Rich text formatting with line breaks preserved
### ⭐ Important Notes
- Mark specific notes as "important" for quick identification
- Important notes are visually highlighted with a distinct indicator
- Toggle importance status with a single click
### 👥 Multi-User Support
- Each note tracks who created it and when
- View the author and timestamp for every note
- Edit history shows when notes were last modified
### 🔒 Access Control
- Users can edit and delete their own notes
- Administrators can edit and delete any note
- All actions are logged for audit purposes
---
## How to Use
### Adding a Note
1. Navigate to a client's detail page by clicking on the client name
2. Scroll down to the **Internal Notes** section
3. Click the **Add Note** button
4. Enter your note content in the text area
5. Optionally, check **Mark as important** for critical information
6. Click **Save Note**
### Viewing Notes
All notes for a client are displayed in the **Internal Notes** section on the client detail page:
- Notes are shown in reverse chronological order (newest first)
- Important notes are highlighted with an amber left border and a star icon
- Each note displays:
- Author's name
- Creation date and time
- Edit indicator if the note was modified
- Note content
### Editing a Note
1. Locate the note you want to edit
2. Click the **Edit** link next to the note
3. Modify the content and/or importance flag
4. Click **Save Changes**
> **Note:** You can only edit notes you created, unless you're an administrator.
### Marking Notes as Important
You can toggle the importance of a note in two ways:
**Method 1: Quick Toggle**
- Click the **Mark Important** or **Unmark** button next to any note
- The page will refresh automatically with the updated status
**Method 2: While Editing**
- Open the note for editing
- Check or uncheck the **Mark as important** checkbox
- Save your changes
### Deleting a Note
1. Locate the note you want to delete
2. Click the **Delete** button next to the note
3. Confirm the deletion when prompted
> **Warning:** Deleting a note is permanent and cannot be undone.
---
## Use Cases
### Client Preferences
```
Example: "Client prefers morning meetings (before 11 AM).
Doesn't like phone calls - always use email."
```
### Special Requirements
```
Example: "All invoices must be sent to finance@client.com
in addition to the main contact. Net 45 payment terms."
```
### Project History
```
Example: "Previous project had scope creep issues.
Make sure to clearly define deliverables upfront."
```
### Communication Notes
```
Example: "Decision maker is Jane (CEO), but contact person
is Bob (Project Manager). Include both on important emails."
```
---
## API Endpoints
For developers integrating with TimeTracker, the following API endpoints are available:
### List Client Notes
```http
GET /api/clients/{client_id}/notes
```
**Query Parameters:**
- `order_by_important` (boolean, optional): Order important notes first
**Response:**
```json
{
"success": true,
"notes": [
{
"id": 1,
"content": "Example note",
"client_id": 5,
"client_name": "Acme Corp",
"user_id": 2,
"author": "john.doe",
"author_name": "John Doe",
"is_important": true,
"created_at": "2025-10-24T10:30:00",
"updated_at": "2025-10-24T10:30:00"
}
]
}
```
### Get Single Note
```http
GET /api/client-notes/{note_id}
```
### Get Important Notes
```http
GET /api/client-notes/important
```
**Query Parameters:**
- `client_id` (integer, optional): Filter by specific client
### Get Recent Notes
```http
GET /api/client-notes/recent
```
**Query Parameters:**
- `limit` (integer, optional, default: 10): Number of notes to return
### Get User's Notes
```http
GET /api/client-notes/user/{user_id}
```
**Query Parameters:**
- `limit` (integer, optional): Number of notes to return
### Toggle Important Flag
```http
POST /clients/{client_id}/notes/{note_id}/toggle-important
```
**Response:**
```json
{
"success": true,
"is_important": true
}
```
---
## Database Schema
The client notes feature uses the following database table:
### `client_notes` Table
| Column | Type | Description |
|---------------|-----------|------------------------------------------|
| `id` | Integer | Primary key |
| `content` | Text | Note content (required) |
| `client_id` | Integer | Foreign key to `clients.id` (required) |
| `user_id` | Integer | Foreign key to `users.id` (required) |
| `is_important`| Boolean | Important flag (default: false) |
| `created_at` | DateTime | Creation timestamp |
| `updated_at` | DateTime | Last update timestamp |
**Indexes:**
- `ix_client_notes_client_id` on `client_id`
- `ix_client_notes_user_id` on `user_id`
- `ix_client_notes_created_at` on `created_at`
- `ix_client_notes_is_important` on `is_important`
**Relationships:**
- Notes are deleted when the associated client is deleted (CASCADE)
- Notes belong to a user (author) and a client
---
## Permissions
### Regular Users
- ✅ View all notes on clients they have access to
- ✅ Create new notes
- ✅ Edit their own notes
- ✅ Delete their own notes
- ✅ Toggle importance on their own notes
- ❌ Edit notes created by other users
- ❌ Delete notes created by other users
### Administrators
- ✅ All regular user permissions
- ✅ Edit any note
- ✅ Delete any note
- ✅ Toggle importance on any note
---
## Security & Privacy
### Internal Only
- Client notes are **never** exposed to clients
- Notes do not appear on invoices, reports, or any client-facing documents
- API endpoints require authentication
### Audit Trail
- All note actions (create, update, delete) are logged in the system event log
- Includes timestamp, user ID, and action details
- Can be reviewed by administrators for compliance
### Data Protection
- Notes are stored in the main database with the same security measures as other sensitive data
- Backup procedures include client notes
- Notes are included in data exports for compliance purposes
---
## Migration Guide
To enable the client notes feature on an existing TimeTracker installation:
### Step 1: Update Code
```bash
git pull origin main
```
### Step 2: Run Database Migration
```bash
# Using Flask-Migrate
flask db upgrade
# Or using Alembic directly
alembic upgrade head
```
### Step 3: Restart Application
```bash
# Docker
docker-compose restart
# Local development
flask run
```
### Verify Installation
1. Navigate to any client detail page
2. You should see the **Internal Notes** section at the bottom
3. Try adding a test note
---
## Troubleshooting
### Notes Section Not Visible
**Problem:** The Internal Notes section doesn't appear on the client page.
**Solution:**
1. Ensure you've run the latest database migration
2. Clear your browser cache
3. Check the browser console for JavaScript errors
4. Verify the user has permission to view clients
### Cannot Edit Notes
**Problem:** Edit button is missing or doesn't work.
**Solution:**
1. Verify you're logged in
2. Check that you're either the note's author or an administrator
3. Ensure JavaScript is enabled in your browser
### API Endpoints Return 404
**Problem:** API calls to note endpoints fail with 404.
**Solution:**
1. Verify the application has been restarted after update
2. Check that the `client_notes_bp` blueprint is registered in `app/__init__.py`
3. Review application logs for import errors
---
## Best Practices
### 1. Be Descriptive
Write clear, detailed notes that will be helpful months from now. Include:
- Context and background
- Specific dates if relevant
- Names of people involved
- Action items or follow-ups
### 2. Use Important Flag Wisely
Reserve the "important" flag for truly critical information:
- Legal or compliance requirements
- Financial terms and conditions
- Critical preferences or restrictions
- Emergency contact information
### 3. Keep Notes Updated
- Review and update notes periodically
- Archive or delete outdated information
- Add new notes when circumstances change
### 4. Maintain Professionalism
Remember that notes are:
- Potentially subject to legal discovery
- May be seen by other team members
- Part of your business records
Always write notes professionally and factually.
### 5. Use Notes for Team Communication
Notes are a great way to share knowledge:
- Document client quirks or preferences
- Share insights from client meetings
- Provide context for new team members
- Record decisions and their rationale
---
## Related Features
- **[Client Management](CLIENT_MANAGEMENT_README.md)** — Complete guide to managing clients
- **[Project Management](#)** — Link projects to clients
- **[Invoice System](INVOICE_FEATURE_README.md)** — Bill clients for your work
- **[Comment System](#)** — Add comments to projects and tasks
---
## Support
If you encounter issues with the Client Notes feature:
1. Check this documentation for solutions
2. Review the [Troubleshooting Guide](SOLUTION_GUIDE.md)
3. Search existing [GitHub Issues](https://github.com/yourusername/TimeTracker/issues)
4. Create a new issue with:
- Steps to reproduce
- Expected behavior
- Actual behavior
- Screenshots if applicable
- Browser and OS information
---
## Changelog
### Version 1.0.0 (2025-10-24)
- ✨ Initial release of Client Notes feature
- ✅ Create, read, update, delete operations
- ✅ Important flag functionality
- ✅ Multi-user support with permissions
- ✅ API endpoints
- ✅ Full test coverage
- ✅ Comprehensive documentation
---
## Contributing
Contributions to improve the Client Notes feature are welcome! Please:
1. Read the [Contributing Guide](CONTRIBUTING.md)
2. Check for existing issues or create a new one
3. Submit pull requests with:
- Clear description of changes
- Unit tests for new functionality
- Updated documentation if needed
---
**[← Back to Documentation Home](README.md)**

493
docs/EXPENSE_TRACKING.md Normal file
View File

@@ -0,0 +1,493 @@
# Expense Tracking Feature
## Overview
The Expense Tracking feature allows users to record, manage, and track business expenses within the TimeTracker application. This comprehensive system includes expense creation, approval workflows, reimbursement tracking, and integration with projects, clients, and invoicing.
## Table of Contents
1. [Features](#features)
2. [User Roles and Permissions](#user-roles-and-permissions)
3. [Creating Expenses](#creating-expenses)
4. [Approval Workflow](#approval-workflow)
5. [Reimbursement Process](#reimbursement-process)
6. [Expense Categories](#expense-categories)
7. [Filtering and Search](#filtering-and-search)
8. [Export and Reporting](#export-and-reporting)
9. [Integration](#integration)
10. [API Endpoints](#api-endpoints)
11. [Database Schema](#database-schema)
## Features
### Core Features
- **Expense Recording**: Track expenses with detailed information including amount, category, vendor, and receipts
- **Multi-Currency Support**: Record expenses in different currencies (EUR, USD, GBP, CHF)
- **Tax Tracking**: Separate tax amount tracking for accurate financial reporting
- **Receipt Management**: Upload and attach receipt files to expenses
- **Approval Workflow**: Multi-stage approval process with admin oversight
- **Reimbursement Tracking**: Track which expenses have been reimbursed
- **Billable Expenses**: Mark expenses as billable to clients
- **Project/Client Association**: Link expenses to specific projects and clients
- **Tags and Notes**: Add tags and detailed notes for better organization
- **Dashboard Analytics**: Visual analytics and summaries of expense data
- **Export Functionality**: Export expense data to CSV format
### Advanced Features
- **Status Tracking**: Track expenses through pending, approved, rejected, and reimbursed states
- **Date Range Filtering**: Filter expenses by date ranges
- **Category Analytics**: View spending breakdown by category
- **Payment Method Tracking**: Record payment methods used for expenses
- **Bulk Operations**: Perform operations on multiple expenses efficiently
- **Integration with Invoicing**: Link billable expenses to client invoices
## User Roles and Permissions
### Regular Users
**Can:**
- Create new expenses
- View their own expenses
- Edit pending expenses they created
- Delete their own pending expenses
- Add receipts and documentation
- View expense status and approval information
**Cannot:**
- Approve or reject expenses
- Mark expenses as reimbursed
- View other users' expenses
- Edit approved or reimbursed expenses
### Admin Users
**Can:**
- All regular user permissions
- View all expenses from all users
- Approve or reject pending expenses
- Mark expenses as reimbursed
- Edit any expense regardless of status
- Delete any expense
- Access full expense analytics dashboard
## Creating Expenses
### Basic Expense Creation
1. Navigate to **Insights → Expenses** in the sidebar
2. Click **New Expense** button
3. Fill in required fields:
- **Title**: Short description of the expense
- **Category**: Select from predefined categories
- **Amount**: Expense amount (excluding tax)
- **Expense Date**: Date the expense was incurred
### Optional Fields
- **Description**: Detailed description of the expense
- **Tax Amount**: Separate tax amount
- **Currency**: Currency code (default: EUR)
- **Project**: Associate with a project
- **Client**: Associate with a client
- **Payment Method**: How the expense was paid
- **Payment Date**: When payment was made
- **Vendor**: Name of the vendor/supplier
- **Receipt Number**: Receipt or invoice number
- **Receipt File**: Upload receipt image or PDF
- **Tags**: Comma-separated tags for organization
- **Notes**: Additional notes
- **Billable**: Mark if expense should be billed to client
- **Reimbursable**: Mark if expense should be reimbursed
### Example: Creating a Travel Expense
```
Title: Flight to Berlin Client Meeting
Description: Round-trip flight for Q4 business review
Category: Travel
Amount: 450.00
Tax Amount: 45.00
Currency: EUR
Expense Date: 2025-10-20
Payment Method: Company Card
Vendor: Lufthansa
Project: [Select Project]
Client: [Select Client]
Billable: ✓ (checked)
Reimbursable: ✗ (unchecked)
Tags: travel, client-meeting, Q4
```
## Approval Workflow
### States
1. **Pending**: Newly created expense awaiting approval
2. **Approved**: Expense approved by admin
3. **Rejected**: Expense rejected with reason
4. **Reimbursed**: Approved expense that has been reimbursed
### Approval Process
#### For Users:
1. Create expense with all required information
2. Submit expense (automatically set to "Pending" status)
3. Wait for admin review
4. Receive notification of approval or rejection
5. If approved and reimbursable, wait for reimbursement
#### For Admins:
1. Navigate to expense list
2. Filter by status: "Pending"
3. Click on expense to view details
4. Review all information, receipts, and documentation
5. Choose action:
- **Approve**: Approves the expense (optionally add approval notes)
- **Reject**: Rejects the expense (must provide rejection reason)
### Rejection Reasons
When rejecting an expense, admins must provide a clear reason:
- Missing or invalid receipt
- Expense not covered by company policy
- Incorrect category
- Amount exceeds limit
- Duplicate expense
- Other (with explanation)
## Reimbursement Process
### For Reimbursable Expenses
1. User creates expense and marks it as "Reimbursable"
2. Admin approves the expense
3. Finance processes reimbursement outside the system
4. Admin marks expense as "Reimbursed" in the system
5. Expense status changes to "Reimbursed" with timestamp
### Tracking Reimbursements
- Dashboard shows count of pending reimbursements
- Filter expenses by reimbursement status
- View reimbursement date and details
- Export reimbursement reports
## Expense Categories
The system provides predefined expense categories:
- **Travel**: Flights, trains, taxis, car rentals
- **Meals**: Business meals, client entertainment
- **Accommodation**: Hotels, short-term rentals
- **Supplies**: Office supplies, materials
- **Software**: Software licenses, subscriptions
- **Equipment**: Hardware, tools, equipment purchases
- **Services**: Professional services, consultants
- **Marketing**: Advertising, promotional materials
- **Training**: Courses, conferences, professional development
- **Other**: Miscellaneous expenses
### Category Analytics
View spending breakdown by category:
- Total amount per category
- Number of expenses per category
- Percentage of total spending
- Trend analysis over time
## Filtering and Search
### Available Filters
- **Search**: Search by title, vendor, notes, or description
- **Status**: Filter by approval status (pending, approved, rejected, reimbursed)
- **Category**: Filter by expense category
- **Project**: Filter by associated project
- **Client**: Filter by associated client
- **User**: (Admin only) Filter by user who created expense
- **Date Range**: Filter by expense date range
- **Billable**: Filter billable/non-billable expenses
- **Reimbursable**: Filter reimbursable/non-reimbursable expenses
### Search Examples
```
Search: "conference"
Status: Approved
Category: Travel
Date Range: 2025-01-01 to 2025-03-31
Billable: Yes
```
## Export and Reporting
### CSV Export
Export filtered expenses to CSV format including:
- Date
- Title
- Category
- Amount
- Tax
- Total
- Currency
- Status
- Vendor
- Payment Method
- Project
- Client
- User
- Billable flag
- Reimbursable flag
- Invoiced flag
- Receipt number
- Notes
### Dashboard Analytics
The expense dashboard provides:
- Total expense count and amount for date range
- Pending approval count
- Pending reimbursement count
- Status breakdown (pending, approved, rejected, reimbursed)
- Category breakdown with amounts
- Recent expenses list
- Visual charts and graphs
### Accessing the Dashboard
1. Navigate to **Insights → Expenses**
2. Click **View Dashboard** in the summary card
3. Adjust date range as needed
4. View analytics and statistics
## Integration
### With Projects
- Associate expenses with specific projects
- View project-specific expense totals
- Include expenses in project cost analysis
- Track billable vs. non-billable project expenses
### With Clients
- Link expenses to client accounts
- Generate client-specific expense reports
- Include billable expenses in client invoices
- Track client-related spending
### With Invoicing
- Mark expenses as billable to clients
- Track which expenses have been invoiced
- Link expenses to specific invoices
- Automatically include billable expenses in invoice generation
## API Endpoints
### List Expenses
```
GET /api/expenses
Query Parameters:
- status: Filter by status
- category: Filter by category
- project_id: Filter by project
- start_date: Start date (YYYY-MM-DD)
- end_date: End date (YYYY-MM-DD)
Response:
{
"expenses": [...],
"count": 10
}
```
### Get Single Expense
```
GET /api/expenses/<expense_id>
Response:
{
"id": 1,
"title": "Travel Expense",
"category": "travel",
"amount": 150.00,
...
}
```
### Create Expense (via Web Form)
```
POST /expenses/create
Form Data:
- title: string (required)
- category: string (required)
- amount: decimal (required)
- expense_date: date (required)
- [additional optional fields]
```
### Approve Expense
```
POST /expenses/<expense_id>/approve
Form Data:
- approval_notes: string (optional)
```
### Reject Expense
```
POST /expenses/<expense_id>/reject
Form Data:
- rejection_reason: string (required)
```
### Mark as Reimbursed
```
POST /expenses/<expense_id>/reimburse
```
## Database Schema
### Expenses Table
```sql
CREATE TABLE expenses (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
project_id INTEGER,
client_id INTEGER,
-- Expense details
title VARCHAR(200) NOT NULL,
description TEXT,
category VARCHAR(50) NOT NULL,
amount NUMERIC(10, 2) NOT NULL,
currency_code VARCHAR(3) NOT NULL DEFAULT 'EUR',
tax_amount NUMERIC(10, 2),
tax_rate NUMERIC(5, 2),
-- Payment information
payment_method VARCHAR(50),
payment_date DATE,
-- Status and approval
status VARCHAR(20) NOT NULL DEFAULT 'pending',
approved_by INTEGER,
approved_at DATETIME,
rejection_reason TEXT,
-- Billing and invoicing
billable BOOLEAN NOT NULL DEFAULT 0,
reimbursable BOOLEAN NOT NULL DEFAULT 1,
invoiced BOOLEAN NOT NULL DEFAULT 0,
invoice_id INTEGER,
reimbursed BOOLEAN NOT NULL DEFAULT 0,
reimbursed_at DATETIME,
-- Date and metadata
expense_date DATE NOT NULL,
receipt_path VARCHAR(500),
receipt_number VARCHAR(100),
vendor VARCHAR(200),
notes TEXT,
tags VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL,
FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE SET NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL,
FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL
);
-- Indexes
CREATE INDEX ix_expenses_user_id ON expenses(user_id);
CREATE INDEX ix_expenses_project_id ON expenses(project_id);
CREATE INDEX ix_expenses_client_id ON expenses(client_id);
CREATE INDEX ix_expenses_expense_date ON expenses(expense_date);
CREATE INDEX ix_expenses_user_date ON expenses(user_id, expense_date);
CREATE INDEX ix_expenses_status_date ON expenses(status, expense_date);
CREATE INDEX ix_expenses_project_date ON expenses(project_id, expense_date);
```
## Best Practices
### For Users
1. **Be Detailed**: Provide clear titles and descriptions
2. **Attach Receipts**: Always upload receipt documentation
3. **Timely Submission**: Submit expenses promptly while details are fresh
4. **Accurate Categorization**: Choose the most appropriate category
5. **Complete Information**: Fill in all relevant optional fields
6. **Project Association**: Link to projects when applicable
7. **Tag Appropriately**: Use tags for easier searching and filtering
### For Admins
1. **Prompt Review**: Review expenses in a timely manner
2. **Clear Communication**: Provide detailed reasons for rejections
3. **Consistent Policy**: Apply expense policies consistently
4. **Documentation Check**: Verify receipt documentation before approval
5. **Amount Verification**: Verify amounts match receipts
6. **Policy Compliance**: Ensure expenses comply with company policy
7. **Regular Audits**: Periodically audit expense patterns
## Troubleshooting
### Common Issues
**Problem**: Can't upload receipt file
- **Solution**: Ensure file is PNG, JPG, GIF, or PDF format under 10MB
**Problem**: Can't edit approved expense
- **Solution**: Only admins can edit approved expenses. Contact admin if changes needed.
**Problem**: Expense not showing in project costs
- **Solution**: Ensure expense is linked to the project and approved
**Problem**: Can't delete expense
- **Solution**: Only pending expenses can be deleted by regular users
**Problem**: Total amount calculation seems wrong
- **Solution**: Check that tax amount is entered correctly; total = amount + tax
## Future Enhancements
Planned features for future releases:
- Automated expense import from credit card statements
- Mobile app for expense submission
- OCR for automatic receipt data extraction
- Approval routing based on amount thresholds
- Multi-level approval workflows
- Expense budget tracking and alerts
- Mileage tracking and calculation
- Per diem calculations
- Corporate card integration
- Real-time currency conversion
## Support
For questions or issues with the Expense Tracking feature:
- Check this documentation
- Review inline help text in the application
- Contact your system administrator
- Check the application logs for error details
## Related Documentation
- [Invoicing Guide](./INVOICING.md)
- [Project Cost Tracking](./PROJECT_COSTS.md)
- [User Roles and Permissions](./PERMISSIONS.md)
- [API Documentation](./API.md)

View File

@@ -0,0 +1,307 @@
# Invoice Extra Goods PDF Export
## Overview
The TimeTracker invoice system now includes **extra goods** (products, services, materials, licenses) in PDF exports. This enhancement allows invoices to include both time-based billing items and additional goods/products in a single professional PDF document.
## Feature Description
### What's New
- **Extra Goods in PDF**: Invoice PDFs now automatically include all extra goods associated with an invoice
- **Rich Details**: Each good displays its name, description, SKU, category, quantity, unit price, and total amount
- **Consistent Formatting**: Extra goods are displayed in the same table as regular invoice items with appropriate styling
- **Dual PDF Support**: Both WeasyPrint (primary) and ReportLab (fallback) generators support extra goods
### What Are Extra Goods?
Extra goods are additional products or services that can be added to invoices beyond time-based billing. They include:
- **Products**: Physical items (hardware, equipment, supplies)
- **Services**: Additional services not tracked by time entries (licenses, subscriptions, one-time services)
- **Materials**: Consumables or raw materials used in projects
- **Licenses**: Software licenses, certifications, permits
- **Other**: Miscellaneous goods and services
## Technical Implementation
### Files Modified
1. **`app/utils/pdf_generator.py`**
- Modified `_generate_items_rows()` to include extra goods
- Added formatting for good name, description, SKU, and category
2. **`app/templates/invoices/pdf_default.html`**
- Added loop to render extra goods in the invoice items table
- Included conditional display of description, SKU, and category
3. **`app/utils/pdf_generator_fallback.py`**
- Modified `_build_items_table()` to include extra goods
- Added multi-line description support for ReportLab
4. **`tests/test_invoices.py`**
- Added 6 comprehensive tests covering unit and smoke testing
- Tests for both primary and fallback PDF generators
### Data Flow
```
Invoice Model
├── items (InvoiceItem) - Time-based billing items
└── extra_goods (ExtraGood) - Additional products/services
PDF Generator reads both
Renders in single table
Professional PDF output
```
### PDF Structure
The invoice PDF table now includes:
1. **Header Row**: Description | Quantity (Hours) | Unit Price | Total Amount
2. **Invoice Items**: Regular time-based billing entries
3. **Extra Goods**: Additional products/services with:
- Primary name (bold)
- Description (if available)
- SKU code (if available)
- Category (product/service/material/license/other)
4. **Footer Rows**: Subtotal | Tax | Total Amount
## Usage Examples
### Adding Extra Goods to an Invoice
```python
from app.models import ExtraGood
from decimal import Decimal
# Create an extra good
good = ExtraGood(
name='Software License',
description='Annual premium license',
category='license',
quantity=Decimal('1.00'),
unit_price=Decimal('299.99'),
sku='LIC-2024-001',
created_by=current_user.id,
invoice_id=invoice.id
)
db.session.add(good)
db.session.commit()
# Recalculate invoice totals to include the good
invoice.calculate_totals()
db.session.commit()
```
### Generating PDF with Extra Goods
```python
from app.utils.pdf_generator import InvoicePDFGenerator
# Generate PDF (automatically includes extra goods)
generator = InvoicePDFGenerator(invoice)
pdf_bytes = generator.generate_pdf()
# Save or send the PDF
with open('invoice.pdf', 'wb') as f:
f.write(pdf_bytes)
```
## Testing
### Running Tests
Run all invoice tests including extra goods tests:
```bash
# All invoice tests
pytest tests/test_invoices.py -v
# Only extra goods tests
pytest tests/test_invoices.py -k "extra_goods" -v
# Unit tests only
pytest tests/test_invoices.py -m unit -k "extra_goods" -v
# Smoke tests only
pytest tests/test_invoices.py -m smoke -k "extra_goods" -v
```
### Test Coverage
The implementation includes:
- **6 new tests** covering extra goods in PDF export
- **Unit tests**: Verify goods are included and properly formatted
- **Smoke tests**: End-to-end PDF generation without errors
- **Both generators**: Tests for WeasyPrint and ReportLab generators
### Test Results Expected
All tests should pass:
-`test_invoice_with_extra_goods` - Association test
-`test_pdf_generator_includes_extra_goods` - Content inclusion
-`test_pdf_generator_extra_goods_formatting` - Formatting verification
-`test_pdf_fallback_generator_includes_extra_goods` - Fallback generator
-`test_pdf_export_with_extra_goods_smoke` - Primary PDF generation
-`test_pdf_export_fallback_with_extra_goods_smoke` - Fallback PDF generation
## User Interface
### How Extra Goods Appear in PDF
**Example PDF Output:**
```
┌──────────────────────────────────────────────────────────────────────────┐
│ INVOICE #INV-20241024-001 │
├────────────────────┬─────────────┬──────────────┬──────────────────────┤
│ Description │ Qty (Hours) │ Unit Price │ Total Amount │
├────────────────────┼─────────────┼──────────────┼──────────────────────┤
│ Web Development │ 40.00 │ 85.00 EUR │ 3,400.00 EUR │
│ (Time entries) │ │ │ │
├────────────────────┼─────────────┼──────────────┼──────────────────────┤
│ SSL Certificate │ 1.00 │ 89.00 EUR │ 89.00 EUR │
│ Wildcard SSL │ │ │ │
│ SKU: SSL-001 │ │ │ │
│ Category: Service│ │ │ │
├────────────────────┼─────────────┼──────────────┼──────────────────────┤
│ Server Credits │ 12.00 │ 50.00 EUR │ 600.00 EUR │
│ Category: Service│ │ │ │
├────────────────────┴─────────────┴──────────────┼──────────────────────┤
│ Subtotal: │ 4,089.00 EUR │
│ Tax (20%): │ 817.80 EUR │
│ Total: │ 4,906.80 EUR │
└─────────────────────────────────────────────────┴──────────────────────┘
```
## API Integration
### REST API Endpoints
Extra goods are automatically included when using invoice API endpoints:
```bash
# Generate and download invoice PDF
GET /invoices/{invoice_id}/pdf
# Response: PDF file with extra goods included
Content-Type: application/pdf
Content-Disposition: attachment; filename="invoice-{number}.pdf"
```
### Custom Templates
If using custom invoice templates, extra goods can be accessed via:
```jinja2
{% for good in invoice.extra_goods %}
<tr>
<td>
{{ good.name }}
{% if good.description %}
<br><small>{{ good.description }}</small>
{% endif %}
{% if good.sku %}
<br><small>SKU: {{ good.sku }}</small>
{% endif %}
</td>
<td>{{ good.quantity }}</td>
<td>{{ format_money(good.unit_price) }}</td>
<td>{{ format_money(good.total_amount) }}</td>
</tr>
{% endfor %}
```
## Benefits
### For Users
- **Comprehensive Billing**: Include both time-based and product-based charges in one invoice
- **Professional Presentation**: Goods display with full details (SKU, category, description)
- **Accurate Totals**: All goods automatically included in invoice calculations
- **Flexibility**: Mix time entries and products/services as needed
### For Developers
- **Clean Code**: Minimal changes, leveraging existing structures
- **Full Test Coverage**: Unit and smoke tests ensure reliability
- **Backward Compatible**: Existing invoices without goods still work perfectly
- **Easy to Extend**: Simple to add more good attributes in the future
## Troubleshooting
### Common Issues
**Issue**: Extra goods not appearing in PDF
- **Solution**: Ensure goods are associated with `invoice_id` correctly
- **Check**: Run `invoice.extra_goods` to verify goods are linked
**Issue**: Totals don't include goods
- **Solution**: Call `invoice.calculate_totals()` after adding goods
- **Check**: Verify `invoice.subtotal` includes good amounts
**Issue**: PDF generation fails with goods
- **Solution**: Check that all required good fields are populated (name, quantity, unit_price)
- **Check**: Review test cases for proper good creation examples
## Future Enhancements
Potential improvements for future versions:
1. **Good Images**: Display product images in PDFs
2. **Grouped Display**: Option to group goods by category
3. **Discount Support**: Apply discounts to individual goods
4. **Tax Per Item**: Different tax rates for different goods
5. **Inventory Integration**: Link goods to inventory management
6. **Localization**: Translate good categories and labels
## Related Documentation
- [Invoice System Overview](ENHANCED_INVOICE_SYSTEM_README.md)
- [Extra Goods Model](../app/models/extra_good.py)
- [PDF Generation Utilities](../app/utils/pdf_generator.py)
- [Testing Guide](../tests/README.md)
## Changelog
### Version 1.0.0 (2024-10-24)
**Added:**
- Extra goods support in PDF export (WeasyPrint)
- Extra goods support in fallback PDF export (ReportLab)
- Rich formatting for good details (name, description, SKU, category)
- 6 comprehensive unit and smoke tests
- Documentation for feature usage
**Modified:**
- `app/utils/pdf_generator.py` - Enhanced `_generate_items_rows()`
- `app/templates/invoices/pdf_default.html` - Added extra goods rendering
- `app/utils/pdf_generator_fallback.py` - Enhanced `_build_items_table()`
- `tests/test_invoices.py` - Added extra goods test suite
**Technical Notes:**
- No database migrations required (extra_goods table already exists)
- No breaking changes to existing functionality
- Backward compatible with all existing invoices
## Support
For issues, questions, or feature requests related to extra goods in invoice PDFs:
1. Check existing documentation
2. Review test cases for usage examples
3. Verify good model fields are correctly populated
4. Ensure invoice totals are recalculated after adding goods
---
**Last Updated**: October 24, 2024
**Author**: TimeTracker Development Team
**Version**: 1.0.0

View File

@@ -0,0 +1,567 @@
# Project Archiving Guide
## Overview
The Project Archiving feature provides a comprehensive solution for organizing completed, cancelled, or inactive projects in TimeTracker. This guide explains how to use the archiving system effectively.
## Table of Contents
1. [What is Project Archiving?](#what-is-project-archiving)
2. [When to Archive Projects](#when-to-archive-projects)
3. [Archiving a Single Project](#archiving-a-single-project)
4. [Bulk Archiving](#bulk-archiving)
5. [Viewing Archived Projects](#viewing-archived-projects)
6. [Unarchiving Projects](#unarchiving-projects)
7. [Archive Metadata](#archive-metadata)
8. [Restrictions on Archived Projects](#restrictions-on-archived-projects)
9. [API Reference](#api-reference)
10. [Best Practices](#best-practices)
---
## What is Project Archiving?
Project archiving allows you to hide completed or inactive projects from your active project lists while preserving all historical data. Archived projects:
- Are removed from active project dropdowns
- Cannot have new time entries added
- Retain all existing time entries and data
- Can be filtered and viewed separately
- Can be unarchived if needed
- Store metadata about when, why, and by whom they were archived
---
## When to Archive Projects
Consider archiving a project when:
- ✅ The project is completed
- ✅ The client contract has ended
- ✅ The project has been cancelled
- ✅ Work is on indefinite hold
- ✅ The maintenance period has ended
- ✅ You want to declutter your active project list
**Do NOT archive projects that:**
- ❌ Are temporarily paused (use "Inactive" status instead)
- ❌ May need time tracking in the near future
- ❌ Are awaiting client feedback
- ❌ Have ongoing maintenance work
---
## Archiving a Single Project
### Step-by-Step Process
1. **Navigate to the Project**
- Go to **Projects** in the main navigation
- Find the project you want to archive
- Click **View** to open the project details
2. **Click Archive Button**
- On the project details page, click the **Archive** button (visible to administrators only)
- You'll be taken to the archive confirmation page
3. **Provide Archive Reason (Optional but Recommended)**
- Enter a reason for archiving in the text field
- This helps with future reference and organization
- Use the **Quick Select** buttons for common reasons:
- Project Completed
- Contract Ended
- Cancelled
- On Hold
- Maintenance Ended
- Or type a custom reason
4. **Confirm Archive**
- Click **Archive Project** to confirm
- The project will be archived immediately
- You'll be redirected to the archived projects list
### Example Archive Reasons
```
✓ "Project delivered on 2025-01-15. Client satisfied with results."
✓ "Annual contract ended. Client chose not to renew."
✓ "Project cancelled by client due to budget constraints."
✓ "Website maintenance complete. No further updates planned."
✓ "Internal tool - replaced with new system."
```
---
## Bulk Archiving
When you need to archive multiple projects at once:
### Using Bulk Archive
1. **Navigate to Projects List**
- Go to **Projects****List All Projects**
2. **Select Projects**
- Check the boxes next to projects you want to archive
- Or click **Select All** to select all visible projects
3. **Open Bulk Actions Menu**
- Click **Bulk Actions (N)** button (where N is the number selected)
- Select **Archive** from the dropdown
4. **Enter Bulk Archive Reason**
- A modal will appear
- Enter a reason that applies to all selected projects
- Or use one of the quick select buttons
- Click **Archive** to confirm
5. **Confirmation**
- All selected projects will be archived with the same reason
- You'll see a success message with the count
### Bulk Archive Tips
- You can archive up to 100 projects at once
- All selected projects will receive the same archive reason
- The current user will be recorded as the archiver for all projects
- Projects with active timers cannot be archived (stop timers first)
---
## Viewing Archived Projects
### Filter Archived Projects
1. **Navigate to Projects List**
- Go to **Projects** in the main navigation
2. **Apply Archive Filter**
- In the filter section, select **Status**: **Archived**
- Click **Filter**
3. **View Archived Project List**
- All archived projects will be displayed
- The list shows:
- Project name and client
- Archive status badge
- Budget and billing information
- Quick actions
### Viewing Individual Archived Project
When viewing an archived project's details page, you'll see:
**Archive Information Section:**
- **Archived on**: Date and time of archiving
- **Archived by**: User who archived the project
- **Reason**: Why the project was archived
All historical data remains accessible:
- Time entries
- Tasks
- Project costs
- Extra goods
- Comments
- Budget information
---
## Unarchiving Projects
If you need to reactivate an archived project:
### Unarchive Process
1. **Navigate to Archived Projects**
- Go to **Projects** with **Status**: **Archived** filter
2. **Open Project Details**
- Click **View** on the project you want to unarchive
3. **Click Unarchive Button**
- Click the **Unarchive** button (administrators only)
- Confirm the action in the dialog
4. **Project Reactivated**
- The project status changes to **Active**
- Archive metadata is cleared
- The project appears in active lists again
- Time tracking can resume
**Note**: Unarchiving a project:
- Removes all archive metadata (reason, date, user)
- Sets the project status to "active"
- Makes the project available for time tracking
- Preserves all historical data
---
## Archive Metadata
Each archived project stores three pieces of metadata:
### 1. Archived At (Timestamp)
- **Type**: Date and time
- **Timezone**: UTC
- **Purpose**: Track when the project was archived
- **Displayed**: Yes (in project details)
- **Example**: "2025-10-24 14:30:00"
### 2. Archived By (User)
- **Type**: User reference
- **Purpose**: Track who archived the project
- **Displayed**: Yes (shows username or full name)
- **Note**: If user is deleted, this field may show "Unknown"
### 3. Archived Reason (Text)
- **Type**: Free text (optional)
- **Max Length**: Unlimited
- **Purpose**: Document why the project was archived
- **Displayed**: Yes (in dedicated section)
- **Can include**: Multi-line text, special characters, emojis
### Viewing Metadata
Archive metadata is displayed on:
- Project details page (Archive Information section)
- API responses (`to_dict()` method)
- Activity logs
- Export reports
---
## Restrictions on Archived Projects
### What You CANNOT Do with Archived Projects
**Time Tracking**
- Cannot start new timers
- Cannot create manual time entries
- Cannot create bulk time entries
- Error message: "Cannot start timer for an archived project. Please unarchive the project first."
**Project Dropdown**
- Archived projects don't appear in:
- Timer start modal
- Manual entry forms
- Bulk entry forms
- Quick timer buttons
### What You CAN Do with Archived Projects
**View Data**
- View project details
- Access time entry history
- See tasks and their status
- Review project costs
- Read comments
**Generate Reports**
- Include in time reports
- Generate invoices from historical data
- Export time entries
- View analytics
**Admin Actions**
- Unarchive the project
- Edit project details (after unarchiving)
- Delete the project (if no time entries)
- Change client assignment
---
## API Reference
### Archive a Project
```python
# Python/Flask
project = Project.query.get(project_id)
project.archive(user_id=current_user.id, reason="Project completed")
db.session.commit()
```
```javascript
// JavaScript/API
fetch('/projects/123/archive', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken
},
body: new URLSearchParams({
'reason': 'Project completed successfully'
})
});
```
### Unarchive a Project
```python
# Python/Flask
project = Project.query.get(project_id)
project.unarchive()
db.session.commit()
```
```javascript
// JavaScript/API
fetch('/projects/123/unarchive', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
});
```
### Get Archive Status
```python
# Check if project is archived
if project.is_archived:
print(f"Archived on: {project.archived_at}")
print(f"Archived by: {project.archived_by_user.username}")
print(f"Reason: {project.archived_reason}")
```
### Project to Dictionary
```python
# Get project data including archive metadata
project_dict = project.to_dict()
# Access archive fields
is_archived = project_dict['is_archived']
archived_at = project_dict['archived_at'] # ISO format string or None
archived_by = project_dict['archived_by'] # User ID or None
archived_reason = project_dict['archived_reason'] # Text or None
```
### Filter Archived Projects
```python
# Get all archived projects
archived_projects = Project.query.filter_by(status='archived').all()
# Get projects archived by specific user
user_archived = Project.query.filter_by(
status='archived',
archived_by=user_id
).all()
# Get projects archived in date range
from datetime import datetime, timedelta
week_ago = datetime.utcnow() - timedelta(days=7)
recently_archived = Project.query.filter(
Project.status == 'archived',
Project.archived_at >= week_ago
).all()
```
### Bulk Archive
```http
POST /projects/bulk-status-change
Content-Type: application/x-www-form-urlencoded
project_ids[]=1&project_ids[]=2&project_ids[]=3&new_status=archived&archive_reason=Bulk+archive+reason
```
---
## Best Practices
### 1. Always Provide Archive Reasons
**Good Practice:**
```
✓ Document WHY the project was archived
✓ Include relevant dates (completion, cancellation)
✓ Mention key outcomes or decisions
✓ Reference client communications if applicable
```
**Example Good Reasons:**
- "Project completed on schedule. Final invoice sent and paid."
- "Client contract ended Q4 2024. No renewal planned."
- "Cancelled due to client budget cuts. 75% of work completed."
### 2. Review Before Archiving
Before archiving, verify:
- [ ] All time entries are logged
- [ ] Final invoice generated (if applicable)
- [ ] All outstanding tasks are resolved or noted
- [ ] Client deliverables are complete
- [ ] No active timers are running
- [ ] Team members are notified
### 3. Use Bulk Archive Strategically
Bulk archive is ideal for:
- End-of-year cleanup
- Multiple projects from same client (contract ended)
- Maintenance projects after completion
- Internal projects that are no longer needed
### 4. Regular Archive Audits
Periodically review archived projects:
- **Monthly**: Review recently archived projects
- **Quarterly**: Audit archive reasons for completeness
- **Yearly**: Consider permanent deletion of very old projects (backup first!)
### 5. Archive vs. Inactive
Use the right status:
**Archive when:**
- Project is completely finished
- No future work expected
- Want to hide from all lists
**Inactive when:**
- Temporarily paused
- Waiting for client
- May resume in near future
- Want to keep in lists but marked as not active
### 6. Unarchive Sparingly
Only unarchive if:
- New work is required on the project
- Contract is renewed
- Client requests additional features
- You need to add historical entries
Consider creating a new project instead if:
- It's a new phase/version
- Significant time has passed
- Scope has changed dramatically
---
## Troubleshooting
### Cannot Start Timer on Archived Project
**Problem**: Error message when starting timer
**Solution**:
1. Check if project is archived (Projects → Filter: Archived)
2. Unarchive the project if work needs to continue
3. Or create a new project for new work
### Cannot Find Archived Project in Dropdown
**Problem**: Archived project doesn't appear in timer dropdown
**Solution**: This is expected behavior. Archived projects are hidden from active lists. To work on an archived project, unarchive it first.
### Lost Archive Reason After Unarchive
**Problem**: Archive reason is gone after unarchiving
**Solution**: This is by design. Archive metadata is cleared when unarchiving. If you need to preserve the reason:
1. Copy the archive reason before unarchiving
2. Add it to project description or comments
3. Or take a screenshot of the archive information
### Bulk Archive Not Working
**Problem**: Some projects not archived in bulk operation
**Solution**:
1. Check if you have admin permissions
2. Ensure no projects have active timers
3. Verify projects are selected (checkboxes checked)
4. Check for error messages in the flash notifications
---
## Migration from Old System
If you're upgrading from a version without archive metadata:
### What Happens to Existing Archived Projects?
- Existing archived projects retain their "archived" status
- Archive metadata fields will be NULL:
- `archived_at`: NULL
- `archived_by`: NULL
- `archived_reason`: NULL
- Projects still function normally
- You can add archive reasons by:
1. Unarchiving the project
2. Re-archiving with a reason
### Manual Migration (Optional)
To add metadata to existing archived projects:
```python
# Example migration script
from app import db
from app.models import Project
from datetime import datetime
# Get all archived projects without metadata
archived_projects = Project.query.filter(
Project.status == 'archived',
Project.archived_at.is_(None)
).all()
# Set archive timestamp to created_at or updated_at
for project in archived_projects:
project.archived_at = project.updated_at or project.created_at
project.archived_reason = "Migrated from old system"
# Leave archived_by as NULL if you don't know who archived it
db.session.commit()
```
---
## Database Schema
For developers and database administrators:
### New Fields in `projects` Table
```sql
ALTER TABLE projects
ADD COLUMN archived_at DATETIME NULL,
ADD COLUMN archived_by INTEGER NULL,
ADD COLUMN archived_reason TEXT NULL,
ADD FOREIGN KEY (archived_by) REFERENCES users(id) ON DELETE SET NULL,
ADD INDEX ix_projects_archived_at (archived_at);
```
### Field Specifications
| Field | Type | Nullable | Index | Default | Foreign Key |
|-------|------|----------|-------|---------|-------------|
| `archived_at` | DATETIME | Yes | Yes | NULL | - |
| `archived_by` | INTEGER | Yes | No | NULL | users(id) ON DELETE SET NULL |
| `archived_reason` | TEXT | Yes | No | NULL | - |
---
## Support and Feedback
If you encounter issues with project archiving:
1. Check this documentation
2. Review the [Troubleshooting](#troubleshooting) section
3. Contact your system administrator
4. Report bugs via GitHub Issues
---
**Document Version**: 1.0
**Last Updated**: October 24, 2025
**TimeTracker Version**: 2.0+

View File

@@ -41,6 +41,7 @@ Welcome to the comprehensive TimeTracker documentation. Everything you need to i
- **[Task Management](TASK_MANAGEMENT_README.md)** — Complete task tracking system
- **[Task Management Overview](TASK_MANAGEMENT.md)** — Task management concepts
- **[Client Management](CLIENT_MANAGEMENT_README.md)** — Manage clients and relationships
- **[Client Notes](CLIENT_NOTES_FEATURE.md)** — Add internal notes about clients
- **[Invoice System](INVOICE_FEATURE_README.md)** — Generate and track invoices
- **[Enhanced Invoice System](ENHANCED_INVOICE_SYSTEM_README.md)** — Advanced invoicing features
- **[Calendar Features](CALENDAR_FEATURES_README.md)** — Calendar view and bulk entry

View File

@@ -0,0 +1,390 @@
# Time Entry Templates - Implementation Summary
## Overview
The Time Entry Templates feature provides reusable templates for frequently logged activities, enabling users to quickly create time entries with pre-filled data including projects, tasks, notes, tags, and durations.
## Implementation Date
**Implementation Date**: January 2025 (Phase 1: Quick Wins Features)
**Completion Date**: October 2025 (Tests and Documentation Added)
## Components
### 1. Database Schema
**Table**: `time_entry_templates`
| Column | Type | Description |
|--------|------|-------------|
| id | Integer | Primary key |
| user_id | Integer | Foreign key to users table |
| name | String(200) | Template name (required) |
| description | Text | Optional template description |
| project_id | Integer | Foreign key to projects table (nullable) |
| task_id | Integer | Foreign key to tasks table (nullable) |
| default_duration_minutes | Integer | Default duration in minutes (nullable) |
| default_notes | Text | Pre-filled notes (nullable) |
| tags | String(500) | Comma-separated tags (nullable) |
| billable | Boolean | Whether entry should be billable (default: true) |
| usage_count | Integer | Number of times template has been used (default: 0) |
| last_used_at | DateTime | Timestamp of last usage (nullable) |
| created_at | DateTime | Timestamp of creation |
| updated_at | DateTime | Timestamp of last update |
**Indexes**:
- `ix_time_entry_templates_user_id` on `user_id`
- `ix_time_entry_templates_project_id` on `project_id`
- `ix_time_entry_templates_task_id` on `task_id`
**Migrations**:
- Initial creation: `migrations/versions/add_quick_wins_features.py`
- Fix nullable constraint: `migrations/versions/024_fix_time_entry_template_nullable.py`
### 2. Backend Implementation
#### Model: `app/models/time_entry_template.py`
**Key Features**:
- Full SQLAlchemy model with relationships to User, Project, and Task
- Property methods for duration conversion (minutes ↔ hours)
- Usage tracking methods: `record_usage()` and `increment_usage()`
- Dictionary serialization via `to_dict()` for API responses
- Automatic timestamp management
#### Routes: `app/routes/time_entry_templates.py`
**Endpoints**:
| Route | Method | Description |
|-------|--------|-------------|
| `/templates` | GET | List all user templates |
| `/templates/create` | GET/POST | Create new template |
| `/templates/<id>` | GET | View template details |
| `/templates/<id>/edit` | GET/POST | Edit existing template |
| `/templates/<id>/delete` | POST | Delete template |
| `/api/templates` | GET | Get templates as JSON |
| `/api/templates/<id>` | GET | Get single template as JSON |
| `/api/templates/<id>/use` | POST | Mark template as used |
**Features**:
- Duplicate name detection per user
- Activity logging for all CRUD operations
- Event tracking for analytics (PostHog)
- Safe database commits with error handling
- User isolation (users can only access their own templates)
### 3. Frontend Implementation
#### Templates (HTML/Jinja2)
**Files**:
- `app/templates/time_entry_templates/list.html` - Template listing page
- `app/templates/time_entry_templates/create.html` - Template creation form
- `app/templates/time_entry_templates/edit.html` - Template editing form
- `app/templates/time_entry_templates/view.html` - Template detail view
**UI Features**:
- Responsive grid layout for template cards
- Empty state with call-to-action
- Real-time usage statistics display
- Dynamic task loading based on selected project
- Inline CRUD actions with confirmation dialogs
- Dark mode support
#### JavaScript Integration
**Template Application Flow**:
1. User clicks "Use Template" button on templates list page
2. JavaScript fetches template data from `/api/templates/<id>`
3. Template data stored in browser sessionStorage
4. Usage count incremented via `/api/templates/<id>/use`
5. User redirected to `/timer/manual?template=<id>`
6. Manual entry page loads template from sessionStorage or fetches via API
7. Form fields pre-filled with template data
8. Duration used to calculate end time based on current time
9. SessionStorage cleared after template application
### 4. Integration Points
#### Timer/Manual Entry Integration
The manual entry page (`app/templates/timer/manual_entry.html`) includes JavaScript code that:
- Checks for `activeTemplate` in sessionStorage
- Falls back to fetching template via `?template=<id>` query parameter
- Pre-fills all form fields (project, task, notes, tags, billable)
- Calculates end time based on start time + duration
- Clears template data after application
#### Activity Logging
All template operations are logged via the Activity model:
- Template creation
- Template updates (with old name if renamed)
- Template deletion
- Template usage (via event tracking)
#### Analytics Tracking
PostHog events tracked:
- `time_entry_template.created`
- `time_entry_template.updated`
- `time_entry_template.deleted`
- `time_entry_template.used` (with usage count)
### 5. Testing
#### Test File: `tests/test_time_entry_templates.py`
**Test Coverage**:
**Model Tests** (`TestTimeEntryTemplateModel`):
- Create template with all fields
- Create template with minimal fields
- Duration property (hours ↔ minutes conversion)
- Usage recording and increment methods
- Dictionary serialization (`to_dict()`)
- Relationship integrity (user, project, task)
- String representation (`__repr__`)
**Route Tests** (`TestTimeEntryTemplateRoutes`):
- List templates (authenticated and unauthenticated)
- Create template page access
- Create template success and validation
- Duplicate name prevention
- Edit template page access and updates
- Delete template
- View single template
**API Tests** (`TestTimeEntryTemplateAPI`):
- Get all templates via API
- Get single template via API
- Mark template as used
**Smoke Tests** (`TestTimeEntryTemplatesSmoke`):
- Templates page renders
- Create page renders
- Complete CRUD workflow
**Integration Tests** (`TestTimeEntryTemplateIntegration`):
- Template with project and task relationships
- Usage tracking over time
- User isolation (templates are user-specific)
**Total**: 30+ test cases covering all aspects of the feature
### 6. Documentation
**User Documentation**: `docs/features/TIME_ENTRY_TEMPLATES.md`
**Contents**:
- Feature overview and benefits
- Step-by-step usage instructions
- Template creation, editing, and deletion
- Use cases and examples
- Best practices for template naming, duration, notes, tags
- Template management and organization tips
- Troubleshooting guide
- API documentation
- Integration notes
- Future enhancement suggestions
**Developer Documentation**: This file
## Usage Statistics
Templates track two key metrics:
1. **Usage Count**: Total number of times the template has been used
2. **Last Used At**: Timestamp of the most recent usage
These statistics help users:
- Identify their most common activities
- Prioritize template organization
- Clean up unused templates
- Understand work patterns
## Security Considerations
1. **User Isolation**: Users can only access their own templates
2. **Authorization Checks**: All routes verify user ownership before allowing operations
3. **CSRF Protection**: All form submissions include CSRF tokens
4. **Input Validation**: Template names are required; duplicate names per user are prevented
5. **Safe Deletes**: Templates can be deleted without affecting existing time entries
6. **SQL Injection Protection**: Parameterized queries via SQLAlchemy ORM
## Performance Considerations
1. **Database Indexes**: Indexes on user_id, project_id, and task_id for fast queries
2. **Efficient Queries**: Templates sorted by last_used_at in descending order
3. **Lazy Loading**: Tasks loaded dynamically via AJAX when project is selected
4. **SessionStorage**: Template data temporarily cached in browser to avoid repeated API calls
5. **Minimal Payload**: API responses include only necessary fields
## Known Limitations
1. **User-Specific**: Templates cannot be shared between users
2. **No Template Categories**: All templates in a single list (consider future enhancement)
3. **No Bulk Operations**: Templates must be created/edited one at a time
4. **No Template Import/Export**: No built-in way to backup or migrate templates
5. **No Template Versioning**: Changes to templates don't maintain history
## Future Enhancements
Potential improvements identified:
1. **Template Organization**:
- Template folders or categories
- Favorite/pin templates
- Custom sorting options
2. **Collaboration**:
- Share templates with team members
- Organization-wide template library
- Template approval workflow
3. **Automation**:
- Template suggestions based on time entry patterns
- Auto-create templates from frequently repeated time entries
- Template scheduling (create time entries automatically)
4. **Advanced Features**:
- Template versioning and history
- Bulk template operations (import/export, duplicate, delete)
- Template usage analytics and reporting
- Template-based time entry validation rules
5. **Integration**:
- Integration with calendar events
- Integration with project management tools
- API webhooks for template usage
## Migration Guide
### Upgrading to Time Entry Templates
If you're upgrading from a version without templates:
1. **Run Database Migration**:
```bash
flask db upgrade
```
or
```bash
alembic upgrade head
```
2. **Verify Table Creation**:
Check that the `time_entry_templates` table exists with all columns and indexes.
3. **Test Template Creation**:
Create a test template to verify the feature works correctly.
4. **User Training**:
Introduce users to the new feature with the user documentation.
### Downgrading (Removing Templates)
If you need to remove the templates feature:
1. **Backup Template Data** (if needed):
```sql
SELECT * FROM time_entry_templates;
```
2. **Run Down Migration**:
```bash
alembic downgrade -1
```
3. **Verify Table Removal**:
Check that the `time_entry_templates` table has been dropped.
## API Examples
### Create Template via Programmatic API
While there's no dedicated API endpoint for creating templates (only UI routes), you can interact with templates via the web API:
```python
import requests
# Get all templates
response = requests.get(
'https://your-timetracker.com/api/templates',
cookies={'session': 'your-session-cookie'}
)
templates = response.json()['templates']
# Get single template
response = requests.get(
'https://your-timetracker.com/api/templates/1',
cookies={'session': 'your-session-cookie'}
)
template = response.json()
# Mark template as used
response = requests.post(
'https://your-timetracker.com/api/templates/1/use',
cookies={'session': 'your-session-cookie'},
headers={'X-CSRFToken': 'csrf-token'}
)
result = response.json()
```
## Changelog
### Version 024 (October 2025)
- Fixed `project_id` nullable constraint mismatch between model and migration
- Added comprehensive test suite (30+ tests)
- Created user documentation
- Created implementation documentation
### Version 022 (January 2025)
- Initial implementation of Time Entry Templates
- Model, routes, and UI templates created
- Integration with manual time entry page
- Activity logging and analytics tracking
## Related Features
- **Time Entries**: Templates pre-fill time entry forms
- **Projects**: Templates can reference specific projects
- **Tasks**: Templates can reference specific tasks
- **Activity Logging**: All template operations are logged
- **Analytics**: Template usage is tracked for insights
## Support and Troubleshooting
For issues with templates:
1. **Check Logs**: Review application logs for error messages
2. **Verify Database**: Ensure the `time_entry_templates` table exists
3. **Test API**: Use browser developer tools to check API responses
4. **Check Permissions**: Verify user has access to templates
5. **Clear Cache**: Clear browser sessionStorage if templates don't load
## Contributing
When contributing to the templates feature:
1. **Run Tests**: Ensure all tests pass before committing
```bash
pytest tests/test_time_entry_templates.py -v
```
2. **Update Documentation**: Keep user and developer docs in sync with code changes
3. **Follow Conventions**: Use existing patterns for routes, models, and templates
4. **Add Tests**: Include tests for any new functionality
5. **Test Integration**: Verify templates work with manual entry page
## Credits
- **Feature Design**: TimeTracker Development Team
- **Implementation**: Initial implementation in Quick Wins phase (January 2025)
- **Testing & Documentation**: Completed October 2025
- **Maintained by**: TimeTracker Project Contributors

View File

@@ -0,0 +1,345 @@
# Time Rounding Preferences - Per-User Settings
## Overview
The Time Rounding Preferences feature allows each user to configure how their time entries are rounded when they stop timers. This provides flexibility for different billing practices and time tracking requirements while maintaining accurate time records.
## Key Features
- **Per-User Configuration**: Each user can set their own rounding preferences independently
- **Multiple Rounding Intervals**: Support for 1, 5, 10, 15, 30, and 60-minute intervals
- **Three Rounding Methods**:
- **Nearest**: Round to the closest interval (standard rounding)
- **Up**: Always round up to the next interval (ceiling)
- **Down**: Always round down to the previous interval (floor)
- **Enable/Disable Toggle**: Users can disable rounding to track exact time
- **Real-time Preview**: Visual examples show how rounding will be applied
## User Guide
### Accessing Rounding Settings
1. Navigate to **Settings** from the user menu
2. Scroll to the **Time Rounding Preferences** section
3. Configure your preferences:
- Toggle **Enable Time Rounding** on/off
- Select your preferred **Rounding Interval**
- Choose your **Rounding Method**
4. Click **Save Settings** to apply changes
### Understanding Rounding Methods
#### Round to Nearest (Default)
Standard mathematical rounding to the closest interval.
**Example** with 15-minute intervals:
- 7 minutes → 0 minutes
- 8 minutes → 15 minutes
- 62 minutes → 60 minutes
- 68 minutes → 75 minutes
#### Always Round Up
Always rounds up to the next interval, ensuring you never under-bill.
**Example** with 15-minute intervals:
- 1 minute → 15 minutes
- 61 minutes → 75 minutes
- 60 minutes → 60 minutes (exact match)
#### Always Round Down
Always rounds down to the previous interval, ensuring conservative billing.
**Example** with 15-minute intervals:
- 14 minutes → 0 minutes
- 74 minutes → 60 minutes
- 75 minutes → 75 minutes (exact match)
### Choosing the Right Settings
**For Freelancers/Contractors:**
- Use **15-minute intervals** with **Round to Nearest** for balanced billing
- Use **Round Up** if client agreements favor rounding up
- Use **5 or 10 minutes** for more granular tracking
**For Internal Time Tracking:**
- Use **No rounding (1 minute)** for exact time tracking
- Use **15 or 30 minutes** for simplified reporting
**For Project-Based Billing:**
- Use **30 or 60 minutes** for project-level granularity
- Use **Round Down** for conservative estimates
## Technical Details
### Database Schema
The following fields are added to the `users` table:
```sql
time_rounding_enabled BOOLEAN DEFAULT 1 NOT NULL
time_rounding_minutes INTEGER DEFAULT 1 NOT NULL
time_rounding_method VARCHAR(10) DEFAULT 'nearest' NOT NULL
```
### Default Values
For new and existing users:
- **Enabled**: `True` (rounding is enabled by default)
- **Minutes**: `1` (no rounding, exact time)
- **Method**: `'nearest'` (standard rounding)
### How Rounding is Applied
1. **Timer Start**: When a user starts a timer, no rounding is applied
2. **Timer Stop**: When a user stops a timer:
- Calculate raw duration (end time - start time)
- Apply user's rounding preferences
- Store rounded duration in `duration_seconds` field
3. **Manual Entries**: Rounding is applied when creating/editing manual entries
### Backward Compatibility
The feature is fully backward compatible:
- If user preferences don't exist, the system falls back to the global `ROUNDING_MINUTES` config setting
- Existing time entries are not retroactively rounded
- Users without the new fields will use global rounding settings
## API Integration
### Get User Rounding Settings
```python
from app.utils.time_rounding import get_user_rounding_settings
settings = get_user_rounding_settings(user)
# Returns: {'enabled': True, 'minutes': 15, 'method': 'nearest'}
```
### Apply Rounding to Duration
```python
from app.utils.time_rounding import apply_user_rounding
raw_seconds = 3720 # 62 minutes
rounded_seconds = apply_user_rounding(raw_seconds, user)
# Returns: 3600 (60 minutes) with 15-min nearest rounding
```
### Manual Rounding
```python
from app.utils.time_rounding import round_time_duration
rounded = round_time_duration(
duration_seconds=3720, # 62 minutes
rounding_minutes=15,
rounding_method='up'
)
# Returns: 4500 (75 minutes)
```
## Migration Guide
### Applying the Migration
Run the Alembic migration to add the new fields:
```bash
# Using Alembic
alembic upgrade head
# Or using the migration script
python migrations/manage_migrations.py upgrade
```
### Migration Details
- **Migration File**: `migrations/versions/027_add_user_time_rounding_preferences.py`
- **Adds**: Three new columns to the `users` table
- **Safe**: Non-destructive, adds columns with default values
- **Rollback**: Supported via downgrade function
### Verifying Migration
```python
from app.models import User
from app import db
# Check if fields exist
user = User.query.first()
assert hasattr(user, 'time_rounding_enabled')
assert hasattr(user, 'time_rounding_minutes')
assert hasattr(user, 'time_rounding_method')
# Check default values
assert user.time_rounding_enabled == True
assert user.time_rounding_minutes == 1
assert user.time_rounding_method == 'nearest'
```
## Configuration
### Available Rounding Intervals
The following intervals are supported:
- `1` - No rounding (exact time)
- `5` - 5 minutes
- `10` - 10 minutes
- `15` - 15 minutes
- `30` - 30 minutes (half hour)
- `60` - 60 minutes (1 hour)
### Available Rounding Methods
Three methods are supported:
- `'nearest'` - Round to nearest interval
- `'up'` - Always round up (ceiling)
- `'down'` - Always round down (floor)
### Global Fallback Setting
If per-user rounding is not configured, the system uses the global setting:
```python
# In app/config.py
ROUNDING_MINUTES = int(os.environ.get('ROUNDING_MINUTES', 1))
```
## Testing
### Running Tests
```bash
# Run all time rounding tests
pytest tests/test_time_rounding*.py -v
# Run specific test suites
pytest tests/test_time_rounding.py -v # Unit tests
pytest tests/test_time_rounding_models.py -v # Model integration tests
pytest tests/test_time_rounding_smoke.py -v # Smoke tests
```
### Test Coverage
The feature includes:
- **Unit Tests**: Core rounding logic (50+ test cases)
- **Model Tests**: Database integration and TimeEntry model
- **Smoke Tests**: End-to-end workflows and edge cases
## Examples
### Example 1: Freelancer with 15-Minute Billing
```python
# User settings
user.time_rounding_enabled = True
user.time_rounding_minutes = 15
user.time_rounding_method = 'nearest'
# Time entry: 62 minutes
# Result: 60 minutes (rounded to nearest 15-min interval)
```
### Example 2: Contractor with Round-Up Policy
```python
# User settings
user.time_rounding_enabled = True
user.time_rounding_minutes = 15
user.time_rounding_method = 'up'
# Time entry: 61 minutes
# Result: 75 minutes (rounded up to next 15-min interval)
```
### Example 3: Exact Time Tracking
```python
# User settings
user.time_rounding_enabled = False
# Time entry: 62 minutes 37 seconds
# Result: 62 minutes 37 seconds (3757 seconds, exact)
```
### Example 4: Conservative Billing
```python
# User settings
user.time_rounding_enabled = True
user.time_rounding_minutes = 30
user.time_rounding_method = 'down'
# Time entry: 62 minutes
# Result: 60 minutes (rounded down to previous 30-min interval)
```
## Troubleshooting
### Rounding Not Applied
**Issue**: Time entries are not being rounded despite settings being enabled.
**Solutions**:
1. Verify rounding is enabled: Check `user.time_rounding_enabled == True`
2. Check rounding interval: Ensure `user.time_rounding_minutes > 1`
3. Verify migration was applied: Check if columns exist in database
4. Clear cache and restart application
### Unexpected Rounding Results
**Issue**: Durations are rounded differently than expected.
**Solutions**:
1. Verify rounding method setting (nearest/up/down)
2. Check the actual rounding interval (minutes value)
3. Test with example calculations using the utility functions
4. Review the rounding method documentation
### Migration Fails
**Issue**: Alembic migration fails to apply.
**Solutions**:
1. Check database permissions
2. Verify no conflicting migrations
3. Run `alembic current` to check migration state
4. Try manual column addition as fallback
5. Check logs for specific error messages
## Best Practices
1. **Choose Appropriate Intervals**: Match your rounding to billing agreements
2. **Document Your Choice**: Note why you chose specific rounding settings
3. **Test Before Production**: Verify rounding behavior with test entries
4. **Communicate with Clients**: Ensure clients understand your rounding policy
5. **Review Regularly**: Periodically review if rounding settings still make sense
6. **Keep Records**: Document any changes to rounding preferences
## Future Enhancements
Potential improvements for future versions:
- Project-specific rounding overrides
- Time-of-day based rounding rules
- Client-specific rounding preferences
- Rounding reports and analytics
- Bulk update of historical entries with new rounding
## Support
For issues or questions:
1. Check this documentation first
2. Review test files for usage examples
3. Check the codebase in `app/utils/time_rounding.py`
4. Open an issue on the project repository
## Changelog
### Version 1.0 (2025-10-24)
- Initial implementation of per-user time rounding preferences
- Support for 6 rounding intervals (1, 5, 10, 15, 30, 60 minutes)
- Support for 3 rounding methods (nearest, up, down)
- UI integration in user settings page
- Comprehensive test coverage
- Full backward compatibility with global rounding settings

369
docs/WEEKLY_TIME_GOALS.md Normal file
View File

@@ -0,0 +1,369 @@
# Weekly Time Goals
## Overview
The Weekly Time Goals feature allows users to set and track weekly hour targets, helping them manage workload and maintain work-life balance. Users can create goals for different weeks, monitor progress in real-time, and review their historical performance.
## Features
### Goal Management
- **Create Weekly Goals**: Set target hours for any week
- **Track Progress**: Real-time progress tracking against targets
- **Status Management**: Automatic status updates (active, completed, failed, cancelled)
- **Notes**: Add context and notes to goals
- **Historical View**: Review past goals and performance
### Dashboard Integration
- **Weekly Goal Widget**: Display current week's progress on the dashboard
- **Quick Actions**: Create or view goals directly from the dashboard
- **Visual Progress**: Color-coded progress bars and statistics
### Analytics
- **Success Rate**: Track completion rate over time
- **Daily Breakdown**: See hours logged per day
- **Average Performance**: View average target vs actual hours
- **Streak Tracking**: Monitor consecutive weeks of completed goals
## User Guide
### Creating a Weekly Goal
1. Navigate to **Weekly Goals** from the sidebar
2. Click **New Goal** button
3. Enter your target hours (e.g., 40 for full-time)
4. Optionally select a specific week (defaults to current week)
5. Add notes if desired (e.g., "Vacation week, reduced hours")
6. Click **Create Goal**
### Quick Presets
The create page includes quick preset buttons for common targets:
- 20 hours (half-time)
- 30 hours (part-time)
- 40 hours (full-time)
- 50 hours (overtime)
### Viewing Goal Progress
#### Dashboard Widget
The dashboard shows your current week's goal with:
- Progress bar
- Actual vs target hours
- Remaining hours
- Days remaining
- Average hours per day needed to reach goal
#### Detailed View
Click on any goal to see:
- Complete week statistics
- Daily breakdown of hours
- All time entries for that week
- Progress visualization
### Editing Goals
1. Navigate to the goal (from Weekly Goals page or dashboard)
2. Click **Edit**
3. Modify target hours, status, or notes
4. Click **Save Changes**
**Note**: Week dates cannot be changed after creation. Create a new goal for a different week instead.
### Understanding Goal Status
Goals automatically update their status based on progress and time:
- **Active**: Current or future week, not yet completed
- **Completed**: Goal met (actual hours ≥ target hours)
- **Failed**: Week ended without meeting goal
- **Cancelled**: Manually cancelled by user
## API Endpoints
### Get Current Week Goal
```http
GET /api/goals/current
```
Returns the goal for the current week for the authenticated user.
**Response:**
```json
{
"id": 1,
"user_id": 1,
"target_hours": 40.0,
"actual_hours": 25.5,
"week_start_date": "2025-10-20",
"week_end_date": "2025-10-26",
"week_label": "Oct 20 - Oct 26, 2025",
"status": "active",
"progress_percentage": 63.8,
"remaining_hours": 14.5,
"days_remaining": 3,
"average_hours_per_day": 4.83
}
```
### List Goals
```http
GET /api/goals?limit=12&status=active
```
List goals for the authenticated user.
**Query Parameters:**
- `limit` (optional): Number of goals to return (default: 12)
- `status` (optional): Filter by status (active, completed, failed, cancelled)
**Response:**
```json
[
{
"id": 1,
"target_hours": 40.0,
"actual_hours": 25.5,
"status": "active",
...
},
...
]
```
### Get Goal Statistics
```http
GET /api/goals/stats
```
Get aggregated statistics about user's goals.
**Response:**
```json
{
"total_goals": 12,
"completed": 8,
"failed": 3,
"active": 1,
"cancelled": 0,
"completion_rate": 72.7,
"average_target_hours": 40.0,
"average_actual_hours": 38.5,
"current_streak": 3
}
```
### Get Specific Goal
```http
GET /api/goals/{goal_id}
```
Get details for a specific goal.
## Database Schema
### weekly_time_goals Table
| Column | Type | Description |
|--------|------|-------------|
| id | Integer | Primary key |
| user_id | Integer | Foreign key to users table |
| target_hours | Float | Target hours for the week |
| week_start_date | Date | Monday of the week |
| week_end_date | Date | Sunday of the week |
| status | String(20) | Goal status (active, completed, failed, cancelled) |
| notes | Text | Optional notes about the goal |
| created_at | DateTime | Creation timestamp |
| updated_at | DateTime | Last update timestamp |
**Indexes:**
- `ix_weekly_time_goals_user_id` on `user_id`
- `ix_weekly_time_goals_week_start_date` on `week_start_date`
- `ix_weekly_time_goals_status` on `status`
- `ix_weekly_time_goals_user_week` on `(user_id, week_start_date)` (composite)
## Best Practices
### Setting Realistic Goals
1. **Consider Your Schedule**: Account for meetings, holidays, and other commitments
2. **Start Conservative**: Begin with achievable targets and adjust based on experience
3. **Account for Non-Billable Time**: Include time for admin tasks, learning, etc.
4. **Review and Adjust**: Use historical data to set more accurate future goals
### Using Goals Effectively
1. **Check Progress Daily**: Review your dashboard widget each morning
2. **Adjust Behavior**: If behind, plan focused work sessions
3. **Celebrate Wins**: Acknowledge completed goals
4. **Learn from Misses**: Review failed goals to understand what went wrong
### Goal Recommendations
- **Full-Time (40h/week)**: Standard work week (8h/day × 5 days)
- **Part-Time (20-30h/week)**: Adjust based on your arrangement
- **Flexible**: Vary by week based on project demands and personal schedule
- **Overtime (45-50h/week)**: Use sparingly; monitor for burnout
## Technical Implementation
### Model: WeeklyTimeGoal
**Location**: `app/models/weekly_time_goal.py`
**Key Properties:**
- `actual_hours`: Calculated from time entries
- `progress_percentage`: (actual_hours / target_hours) × 100
- `remaining_hours`: target_hours - actual_hours
- `is_completed`: actual_hours ≥ target_hours
- `days_remaining`: Days left in the week
- `average_hours_per_day`: Avg hours per day needed to meet goal
**Key Methods:**
- `update_status()`: Auto-update status based on progress and date
- `get_current_week_goal(user_id)`: Get current week's goal for user
- `get_or_create_current_week(user_id, default_target_hours)`: Get or create current week goal
### Routes: weekly_goals Blueprint
**Location**: `app/routes/weekly_goals.py`
**Web Routes:**
- `GET /goals` - Goals overview page
- `GET /goals/create` - Create goal form
- `POST /goals/create` - Create goal handler
- `GET /goals/<id>` - View specific goal
- `GET /goals/<id>/edit` - Edit goal form
- `POST /goals/<id>/edit` - Update goal handler
- `POST /goals/<id>/delete` - Delete goal handler
**API Routes:**
- `GET /api/goals/current` - Get current week goal
- `GET /api/goals` - List goals
- `GET /api/goals/<id>` - Get specific goal
- `GET /api/goals/stats` - Get goal statistics
### Templates
**Location**: `app/templates/weekly_goals/`
- `index.html` - Goals overview and history
- `create.html` - Create new goal
- `edit.html` - Edit existing goal
- `view.html` - Detailed goal view with daily breakdown
### Dashboard Widget
**Location**: `app/templates/main/dashboard.html`
Displays current week's goal with:
- Progress bar
- Key statistics
- Quick access links
## Migration
The feature is added via Alembic migration `027_add_weekly_time_goals.py`.
To apply the migration:
```bash
# Using make
make db-upgrade
# Or directly with alembic
alembic upgrade head
```
## Testing
### Running Tests
```bash
# All weekly goals tests
pytest tests/test_weekly_goals.py -v
# Specific test categories
pytest tests/test_weekly_goals.py -m unit
pytest tests/test_weekly_goals.py -m models
pytest tests/test_weekly_goals.py -m smoke
```
### Test Coverage
The test suite includes:
- **Model Tests**: Goal creation, calculations, status updates
- **Route Tests**: CRUD operations via web interface
- **API Tests**: All API endpoints
- **Integration Tests**: Dashboard widget, relationships
## Troubleshooting
### Goal Not Showing on Dashboard
**Issue**: Current week goal created but not visible on dashboard.
**Solutions**:
1. Refresh the page to reload goal data
2. Verify the goal is for the current week (check week_start_date)
3. Ensure goal status is not 'cancelled'
### Progress Not Updating
**Issue**: Logged time but progress bar hasn't moved.
**Solutions**:
1. Ensure time entries have end_time set (not active timers)
2. Verify time entries are within the week's date range
3. Check that time entries belong to the correct user
4. Refresh the page to recalculate
### Cannot Create Goal for Week
**Issue**: Error when creating goal for specific week.
**Solutions**:
1. Check if a goal already exists for that week
2. Verify target_hours is positive
3. Ensure week_start_date is a Monday (if specified)
## Future Enhancements
Potential future improvements:
- Goal templates (e.g., "Standard Week", "Light Week")
- Team goals and comparisons
- Goal recommendations based on historical data
- Notifications when falling behind
- Integration with calendar for automatic adjustments
- Monthly and quarterly goal aggregations
- Export goal reports
## Related Features
- **Time Tracking**: Time entries count toward weekly goals
- **Dashboard**: Primary interface for goal monitoring
- **Reports**: View time data that feeds into goals
- **User Preferences**: Week start day affects goal calculations
## Support
For issues or questions:
1. Check the [FAQ](../README.md#faq)
2. Review [Time Tracking documentation](TIME_TRACKING.md)
3. Open an issue on GitHub
4. Contact the development team
---
**Last Updated**: October 24, 2025
**Feature Version**: 1.0
**Migration**: 027_add_weekly_time_goals

View File

@@ -0,0 +1,281 @@
# Time Entry Notes Templates - Reusable Note Templates
## Overview
Time Entry Templates allow you to create reusable templates for frequently logged activities, saving time and ensuring consistency. This feature is particularly useful for recurring tasks like meetings, standups, client calls, or any activities you log regularly.
## Features
- **Quick-start templates** for common time entries
- **Pre-filled project, task, and notes** to reduce data entry
- **Default duration** settings for consistent time tracking
- **Tag templates** for better organization
- **Usage tracking** to see which templates you use most often
- **Billable/non-billable** defaults
## How to Use Time Entry Templates
### Creating a Template
1. Navigate to **Templates** from the main navigation menu
2. Click **"New Template"** or **"Create Your First Template"**
3. Fill in the template details:
- **Template Name** (required): A descriptive name for the template (e.g., "Daily Standup", "Client Call")
- **Project** (optional): The default project for this template
- **Task** (optional): The default task within the project
- **Default Duration** (optional): The typical duration in hours (e.g., 0.5 for 30 minutes, 1.5 for 90 minutes)
- **Default Notes** (optional): Pre-filled notes that will appear when using the template
- **Tags** (optional): Comma-separated tags for categorization
- **Billable** (optional): Whether time entries from this template should be billable by default
4. Click **"Create Template"**
### Using a Template
There are two ways to use a template:
#### Method 1: From the Templates Page
1. Navigate to **Templates**
2. Find the template you want to use
3. Click the **"Use Template"** button
4. You'll be redirected to the manual time entry page with all fields pre-filled
5. Adjust the start and end times as needed
6. Click **"Log Time"** to create the entry
#### Method 2: Direct Link
Templates can be accessed directly via URL query parameters:
```
/timer/manual?template=<template_id>
```
### Editing a Template
1. Navigate to **Templates**
2. Find the template you want to edit
3. Click the **edit icon** (pencil)
4. Update the template details
5. Click **"Update Template"**
### Deleting a Template
1. Navigate to **Templates**
2. Find the template you want to delete
3. Click the **delete icon** (trash can)
4. Confirm the deletion in the dialog
## Template Details
Each template displays:
- **Template name** and optional description
- **Associated project** (if specified)
- **Associated task** (if specified)
- **Default duration** (if specified)
- **Default notes** (preview of first few lines)
- **Tags** (if specified)
- **Usage statistics**: How many times the template has been used
- **Last used**: When the template was last used
## Use Cases
### Daily Recurring Activities
Create templates for activities you do every day:
- **Daily Standup Meeting**: Project: "Internal", Duration: 0.25 hours (15 min)
- **Email Processing**: Project: "Administrative", Duration: 0.5 hours
- **Code Review**: Project: "Development", Notes: "Reviewed team pull requests"
### Client-Specific Templates
Create templates for regular client work:
- **Weekly Client Check-in**: Project: "Client A", Duration: 1 hour
- **Monthly Reporting**: Project: "Client B", Duration: 2 hours
### Task-Specific Templates
Create templates for specific types of work:
- **Bug Fixes**: Tags: "bug,development", Billable: Yes
- **Documentation**: Tags: "documentation,writing", Billable: No
- **Training**: Tags: "learning,training", Billable: No
## Best Practices
### Template Naming
- Use clear, descriptive names that indicate the activity
- Include the project name if you have templates for multiple projects
- Use consistent naming conventions (e.g., "Weekly [Activity]", "Monthly [Activity]")
### Default Duration
- Set realistic default durations based on historical data
- Use common increments (0.25, 0.5, 1.0, 2.0 hours)
- Leave duration empty if the activity varies significantly in length
### Default Notes
- Include structure or prompts for what to include
- Use bullet points or questions to guide note-taking
- Examples:
```
- Topics discussed:
- Action items:
- Next steps:
```
### Tags
- Create a consistent tagging system across templates
- Use tags for reporting and filtering (e.g., "meeting", "development", "admin")
- Keep tags lowercase and short
### Maintenance
- Review your templates quarterly
- Delete unused templates to keep the list manageable
- Update templates as your work patterns change
- Check usage statistics to identify which templates are most valuable
## Template Management Tips
### Organizing Templates
Templates are sorted by last used date by default, so your most frequently used templates appear at the top. This makes it easy to access your most common activities quickly.
### Template Usage Tracking
The system tracks:
- **Usage count**: Total number of times the template has been used
- **Last used**: When the template was last applied
This data helps you:
- Identify your most common activities
- Clean up unused templates
- Understand your work patterns
### Sharing Templates
Templates are user-specific and cannot be shared directly with other users. However, admins can:
- Document standard templates in the team wiki
- Provide template "recipes" for common activities
- Export and import template configurations (if bulk operations are available)
## Technical Notes
### Template Application
When you use a template:
1. The template's usage count increments
2. The last used timestamp updates
3. All template fields populate the manual entry form
4. The template's default duration calculates the end time based on the current time
5. The template data is cleared from session storage after application
### Duration Handling
- Templates store duration in minutes internally
- The UI displays duration in hours (decimal format)
- When using a template, the duration is applied from the current time forward
- You can adjust start and end times manually after applying the template
### Data Persistence
- Templates are stored in the database and persist across sessions
- Template data is temporarily stored in browser sessionStorage during the "Use Template" flow
- SessionStorage is cleared after the template is applied to prevent accidental reuse
## API Access
Templates can be accessed programmatically via the API:
### List Templates
```http
GET /api/templates
```
Returns all templates for the authenticated user.
### Get Single Template
```http
GET /api/templates/<template_id>
```
Returns details for a specific template.
### Mark Template as Used
```http
POST /api/templates/<template_id>/use
```
Increments the usage count and updates the last used timestamp.
## Integration with Other Features
### Projects and Tasks
- Templates can reference specific projects and tasks
- When a project is archived or deleted, templates remain but show a warning
- Task selection is dynamic based on the selected project
### Time Entries
- Templates pre-fill time entry forms but don't create entries automatically
- All template fields can be modified before creating the time entry
- Templates don't override user preferences for billability
### Reporting
- Time entries created from templates are tracked like any other entry
- Tags from templates help with filtering and reporting
- Template usage statistics are separate from time entry reporting
## Troubleshooting
### Template Not Loading
If a template doesn't load when you click "Use Template":
1. Check browser console for JavaScript errors
2. Ensure JavaScript is enabled in your browser
3. Try refreshing the page and clicking the template again
4. Clear your browser's sessionStorage and try again
### Template Fields Not Pre-filling
If template fields don't pre-fill the form:
1. Verify the template has the fields populated
2. Check that the project/task still exist and are active
3. Ensure you're using a modern browser with sessionStorage support
### Template Not Appearing
If you created a template but don't see it:
1. Refresh the templates page
2. Check that you're logged in as the correct user (templates are user-specific)
3. Verify the template was created successfully (check for success message)
## Future Enhancements
Potential future features for templates:
- Template categories or folders for better organization
- Template sharing between users or teams
- Template cloning for quick creation of similar templates
- Bulk template import/export
- Template suggestions based on time entry patterns
- Template versioning and history
## Related Documentation
- [Time Tracking Guide](./TIME_TRACKING.md)
- [Manual Time Entry](./MANUAL_TIME_ENTRY.md)
- [Projects and Tasks](./PROJECTS_AND_TASKS.md)
- [Reporting and Analytics](./REPORTING.md)
## Support
If you encounter issues with Time Entry Templates:
1. Check this documentation for troubleshooting tips
2. Review the application logs for error messages
3. Contact your system administrator
4. Report bugs on the project's GitHub repository

View File

@@ -0,0 +1,71 @@
"""Add client notes table for internal notes about clients
Revision ID: 025
Revises: 024
Create Date: 2025-10-24 00:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '025'
down_revision = '024'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create client_notes table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Check if client_notes table already exists
if 'client_notes' not in inspector.get_table_names():
op.create_table('client_notes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('client_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('is_important', sa.Boolean(), nullable=False, server_default=sa.text('false')),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for better performance
op.create_index('ix_client_notes_client_id', 'client_notes', ['client_id'], unique=False)
op.create_index('ix_client_notes_user_id', 'client_notes', ['user_id'], unique=False)
op.create_index('ix_client_notes_created_at', 'client_notes', ['created_at'], unique=False)
op.create_index('ix_client_notes_is_important', 'client_notes', ['is_important'], unique=False)
print("✓ Created client_notes table")
else:
print(" client_notes table already exists")
def downgrade() -> None:
"""Drop client_notes table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Check if client_notes table exists before trying to drop it
if 'client_notes' in inspector.get_table_names():
try:
# Drop indexes first
op.drop_index('ix_client_notes_is_important', table_name='client_notes')
op.drop_index('ix_client_notes_created_at', table_name='client_notes')
op.drop_index('ix_client_notes_user_id', table_name='client_notes')
op.drop_index('ix_client_notes_client_id', table_name='client_notes')
# Drop the table
op.drop_table('client_notes')
print("✓ Dropped client_notes table")
except Exception as e:
print(f"⚠ Warning dropping client_notes table: {e}")
else:
print(" client_notes table does not exist")

View File

@@ -0,0 +1,96 @@
"""Add project archiving metadata fields
Revision ID: 026
Revises: 025
Create Date: 2025-10-24 00:00:00
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# revision identifiers, used by Alembic.
revision = '026'
down_revision = '025'
branch_labels = None
depends_on = None
def upgrade():
"""Add archived_at, archived_by, and archived_reason columns to projects table"""
bind = op.get_bind()
dialect_name = bind.dialect.name if bind else 'generic'
try:
with op.batch_alter_table('projects', schema=None) as batch_op:
# Add archived_at timestamp field
batch_op.add_column(sa.Column('archived_at', sa.DateTime(), nullable=True))
# Add archived_by user reference (who archived the project)
batch_op.add_column(sa.Column('archived_by', sa.Integer(), nullable=True))
# Add archived_reason text field (why the project was archived)
batch_op.add_column(sa.Column('archived_reason', sa.Text(), nullable=True))
# Create foreign key for archived_by
try:
batch_op.create_foreign_key(
'fk_projects_archived_by_users',
'users',
['archived_by'],
['id'],
ondelete='SET NULL'
)
except Exception as e:
print(f"⚠ Warning creating foreign key for archived_by: {e}")
# Create index on archived_at for faster filtering
try:
batch_op.create_index('ix_projects_archived_at', ['archived_at'])
except Exception as e:
print(f"⚠ Warning creating index on archived_at: {e}")
print("✓ Added project archiving metadata fields")
except Exception as e:
print(f"⚠ Warning adding archiving metadata fields: {e}")
def downgrade():
"""Remove archived_at, archived_by, and archived_reason columns from projects table"""
try:
with op.batch_alter_table('projects', schema=None) as batch_op:
# Drop index
try:
batch_op.drop_index('ix_projects_archived_at')
except Exception:
pass
# Drop foreign key
try:
batch_op.drop_constraint('fk_projects_archived_by_users', type_='foreignkey')
except Exception:
pass
# Drop columns
try:
batch_op.drop_column('archived_reason')
except Exception:
pass
try:
batch_op.drop_column('archived_by')
except Exception:
pass
try:
batch_op.drop_column('archived_at')
except Exception:
pass
print("✓ Removed project archiving metadata fields")
except Exception as e:
print(f"⚠ Warning removing archiving metadata fields: {e}")

View File

@@ -0,0 +1,70 @@
"""Add user time rounding preferences
Revision ID: 027
Revises: 026
Create Date: 2025-10-24 00:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '027'
down_revision = '026'
branch_labels = None
depends_on = None
def upgrade():
"""Add time rounding preference fields to users table"""
bind = op.get_bind()
dialect_name = bind.dialect.name if bind else 'generic'
# Add time rounding preferences to users table
try:
# Enable/disable time rounding for this user
op.add_column('users', sa.Column('time_rounding_enabled', sa.Boolean(), nullable=False, server_default='1'))
print("✓ Added time_rounding_enabled column to users table")
except Exception as e:
print(f"⚠ Warning adding time_rounding_enabled column: {e}")
try:
# Rounding interval in minutes (1, 5, 10, 15, 30, 60)
# Default to 1 (no rounding, use exact time)
op.add_column('users', sa.Column('time_rounding_minutes', sa.Integer(), nullable=False, server_default='1'))
print("✓ Added time_rounding_minutes column to users table")
except Exception as e:
print(f"⚠ Warning adding time_rounding_minutes column: {e}")
try:
# Rounding method: 'nearest', 'up', 'down'
# 'nearest' = round to nearest interval
# 'up' = always round up (ceil)
# 'down' = always round down (floor)
op.add_column('users', sa.Column('time_rounding_method', sa.String(10), nullable=False, server_default='nearest'))
print("✓ Added time_rounding_method column to users table")
except Exception as e:
print(f"⚠ Warning adding time_rounding_method column: {e}")
def downgrade():
"""Remove time rounding preference fields from users table"""
try:
op.drop_column('users', 'time_rounding_method')
print("✓ Dropped time_rounding_method column from users table")
except Exception as e:
print(f"⚠ Warning dropping time_rounding_method column: {e}")
try:
op.drop_column('users', 'time_rounding_minutes')
print("✓ Dropped time_rounding_minutes column from users table")
except Exception as e:
print(f"⚠ Warning dropping time_rounding_minutes column: {e}")
try:
op.drop_column('users', 'time_rounding_enabled')
print("✓ Dropped time_rounding_enabled column from users table")
except Exception as e:
print(f"⚠ Warning dropping time_rounding_enabled column: {e}")

View File

@@ -0,0 +1,79 @@
"""Add weekly time goals table for tracking weekly hour targets
Revision ID: 028
Revises: 027
Create Date: 2025-10-24 12:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '028'
down_revision = '027'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create weekly_time_goals table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Check if weekly_time_goals table already exists
if 'weekly_time_goals' not in inspector.get_table_names():
op.create_table('weekly_time_goals',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('target_hours', sa.Float(), nullable=False),
sa.Column('week_start_date', sa.Date(), nullable=False),
sa.Column('week_end_date', sa.Date(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False, server_default='active'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes for better performance
op.create_index('ix_weekly_time_goals_user_id', 'weekly_time_goals', ['user_id'], unique=False)
op.create_index('ix_weekly_time_goals_week_start_date', 'weekly_time_goals', ['week_start_date'], unique=False)
op.create_index('ix_weekly_time_goals_status', 'weekly_time_goals', ['status'], unique=False)
# Create composite index for finding current week goals efficiently
op.create_index(
'ix_weekly_time_goals_user_week',
'weekly_time_goals',
['user_id', 'week_start_date'],
unique=False
)
print("✓ Created weekly_time_goals table")
else:
print(" weekly_time_goals table already exists")
def downgrade() -> None:
"""Drop weekly_time_goals table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Check if weekly_time_goals table exists before trying to drop it
if 'weekly_time_goals' in inspector.get_table_names():
try:
# Drop indexes first
op.drop_index('ix_weekly_time_goals_user_week', table_name='weekly_time_goals')
op.drop_index('ix_weekly_time_goals_status', table_name='weekly_time_goals')
op.drop_index('ix_weekly_time_goals_week_start_date', table_name='weekly_time_goals')
op.drop_index('ix_weekly_time_goals_user_id', table_name='weekly_time_goals')
# Drop the table
op.drop_table('weekly_time_goals')
print("✓ Dropped weekly_time_goals table")
except Exception as e:
print(f"⚠ Warning dropping weekly_time_goals table: {e}")
else:
print(" weekly_time_goals table does not exist")

View File

@@ -0,0 +1,188 @@
"""Add expenses table for expense tracking
Revision ID: 029
Revises: 028
Create Date: 2025-10-24 00:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '029'
down_revision = '028'
branch_labels = None
depends_on = None
def _has_table(inspector, name: str) -> bool:
"""Check if a table exists"""
try:
return name in inspector.get_table_names()
except Exception:
return False
def upgrade() -> None:
"""Create expenses table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Determine database dialect for proper default values
dialect_name = bind.dialect.name
print(f"[Migration 029] Running on {dialect_name} database")
# Set appropriate boolean defaults based on database
if dialect_name == 'sqlite':
bool_true_default = '1'
bool_false_default = '0'
timestamp_default = "(datetime('now'))"
elif dialect_name == 'postgresql':
bool_true_default = 'true'
bool_false_default = 'false'
timestamp_default = 'CURRENT_TIMESTAMP'
else: # MySQL/MariaDB and others
bool_true_default = '1'
bool_false_default = '0'
timestamp_default = 'CURRENT_TIMESTAMP'
# Create expenses table if it doesn't exist
if not _has_table(inspector, 'expenses'):
print("[Migration 029] Creating expenses table...")
try:
# Check if related tables exist for conditional FKs
has_projects = _has_table(inspector, 'projects')
has_clients = _has_table(inspector, 'clients')
has_invoices = _has_table(inspector, 'invoices')
has_users = _has_table(inspector, 'users')
# Build foreign key constraints
fk_constraints = []
if has_users:
fk_constraints.extend([
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='fk_expenses_user_id', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['approved_by'], ['users.id'], name='fk_expenses_approved_by', ondelete='SET NULL'),
])
print("[Migration 029] Including user FKs")
else:
print("[Migration 029] ⚠ Skipping user FKs (users table doesn't exist)")
if has_projects:
fk_constraints.append(
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='fk_expenses_project_id', ondelete='SET NULL')
)
print("[Migration 029] Including project_id FK")
else:
print("[Migration 029] ⚠ Skipping project_id FK (projects table doesn't exist)")
if has_clients:
fk_constraints.append(
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], name='fk_expenses_client_id', ondelete='SET NULL')
)
print("[Migration 029] Including client_id FK")
else:
print("[Migration 029] ⚠ Skipping client_id FK (clients table doesn't exist)")
if has_invoices:
fk_constraints.append(
sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], name='fk_expenses_invoice_id', ondelete='SET NULL')
)
print("[Migration 029] Including invoice_id FK")
else:
print("[Migration 029] ⚠ Skipping invoice_id FK (invoices table doesn't exist)")
op.create_table(
'expenses',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=True),
sa.Column('client_id', sa.Integer(), nullable=True),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=50), nullable=False),
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'),
sa.Column('tax_amount', sa.Numeric(precision=10, scale=2), nullable=True, server_default='0'),
sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=True, server_default='0'),
sa.Column('payment_method', sa.String(length=50), nullable=True),
sa.Column('payment_date', sa.Date(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
sa.Column('approved_by', sa.Integer(), nullable=True),
sa.Column('approved_at', sa.DateTime(), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('billable', sa.Boolean(), nullable=False, server_default=sa.text(bool_false_default)),
sa.Column('reimbursable', sa.Boolean(), nullable=False, server_default=sa.text(bool_true_default)),
sa.Column('invoiced', sa.Boolean(), nullable=False, server_default=sa.text(bool_false_default)),
sa.Column('invoice_id', sa.Integer(), nullable=True),
sa.Column('reimbursed', sa.Boolean(), nullable=False, server_default=sa.text(bool_false_default)),
sa.Column('reimbursed_at', sa.DateTime(), nullable=True),
sa.Column('expense_date', sa.Date(), nullable=False),
sa.Column('receipt_path', sa.String(length=500), nullable=True),
sa.Column('receipt_number', sa.String(length=100), nullable=True),
sa.Column('vendor', sa.String(length=200), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('tags', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text(timestamp_default)),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text(timestamp_default)),
*fk_constraints # Include FKs during table creation for SQLite compatibility
)
print("[Migration 029] ✓ Table created with foreign keys")
except Exception as e:
print(f"[Migration 029] ✗ Error creating table: {e}")
raise
# Create indexes
print("[Migration 029] Creating indexes...")
try:
op.create_index('ix_expenses_user_id', 'expenses', ['user_id'])
op.create_index('ix_expenses_project_id', 'expenses', ['project_id'])
op.create_index('ix_expenses_client_id', 'expenses', ['client_id'])
op.create_index('ix_expenses_approved_by', 'expenses', ['approved_by'])
op.create_index('ix_expenses_invoice_id', 'expenses', ['invoice_id'])
op.create_index('ix_expenses_expense_date', 'expenses', ['expense_date'])
# Composite indexes for common query patterns
op.create_index('ix_expenses_user_date', 'expenses', ['user_id', 'expense_date'])
op.create_index('ix_expenses_status_date', 'expenses', ['status', 'expense_date'])
op.create_index('ix_expenses_project_date', 'expenses', ['project_id', 'expense_date'])
print("[Migration 029] ✓ Indexes created")
except Exception as e:
print(f"[Migration 029] ✗ Error creating indexes: {e}")
raise
print("[Migration 029] ✓ Migration completed successfully")
else:
print("[Migration 029] ⚠ Table already exists, skipping")
def downgrade() -> None:
"""Drop expenses table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
if _has_table(inspector, 'expenses'):
print("[Migration 029] Dropping expenses table...")
try:
# Drop indexes first
try:
op.drop_index('ix_expenses_project_date', 'expenses')
op.drop_index('ix_expenses_status_date', 'expenses')
op.drop_index('ix_expenses_user_date', 'expenses')
op.drop_index('ix_expenses_expense_date', 'expenses')
op.drop_index('ix_expenses_invoice_id', 'expenses')
op.drop_index('ix_expenses_approved_by', 'expenses')
op.drop_index('ix_expenses_client_id', 'expenses')
op.drop_index('ix_expenses_project_id', 'expenses')
op.drop_index('ix_expenses_user_id', 'expenses')
except Exception:
pass
# Drop table
op.drop_table('expenses')
print("[Migration 029] ✓ Table dropped")
except Exception as e:
print(f"[Migration 029] ✗ Error dropping table: {e}")

View File

@@ -0,0 +1,256 @@
"""Add permission system with roles and permissions
Revision ID: 030
Revises: 029
Create Date: 2025-10-24 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
from sqlalchemy import Table, Column, Integer, String, Boolean, DateTime, MetaData
from sqlalchemy.sql import table, column
# revision identifiers, used by Alembic.
revision = '030'
down_revision = '029'
branch_labels = None
depends_on = None
def upgrade():
"""Create tables for advanced permission system"""
# Create permissions table
op.create_table('permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('category', sa.String(length=50), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_permissions_name', 'permissions', ['name'], unique=True)
op.create_index('idx_permissions_category', 'permissions', ['category'])
# Create roles table
op.create_table('roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('is_system_role', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_roles_name', 'roles', ['name'], unique=True)
# Create role_permissions association table
op.create_table('role_permissions',
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('permission_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('role_id', 'permission_id')
)
# Create user_roles association table
op.create_table('user_roles',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('assigned_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('user_id', 'role_id')
)
# Seed default permissions and roles
seed_permissions_and_roles()
def seed_permissions_and_roles():
"""Seed default permissions and roles into the database"""
# Define permissions table for bulk insert
permissions_table = table('permissions',
column('id', Integer),
column('name', String),
column('description', String),
column('category', String),
column('created_at', DateTime)
)
# Define roles table for bulk insert
roles_table = table('roles',
column('id', Integer),
column('name', String),
column('description', String),
column('is_system_role', Boolean),
column('created_at', DateTime),
column('updated_at', DateTime)
)
# Define role_permissions association table
role_permissions_table = table('role_permissions',
column('role_id', Integer),
column('permission_id', Integer),
column('created_at', DateTime)
)
# Define user_roles association table
user_roles_table = table('user_roles',
column('user_id', Integer),
column('role_id', Integer),
column('assigned_at', DateTime)
)
now = datetime.utcnow()
# Default permissions data
permissions_data = [
# Time Entry Permissions (1-7)
{'id': 1, 'name': 'view_own_time_entries', 'description': 'View own time entries', 'category': 'time_entries'},
{'id': 2, 'name': 'view_all_time_entries', 'description': 'View all time entries from all users', 'category': 'time_entries'},
{'id': 3, 'name': 'create_time_entries', 'description': 'Create time entries', 'category': 'time_entries'},
{'id': 4, 'name': 'edit_own_time_entries', 'description': 'Edit own time entries', 'category': 'time_entries'},
{'id': 5, 'name': 'edit_all_time_entries', 'description': 'Edit time entries from all users', 'category': 'time_entries'},
{'id': 6, 'name': 'delete_own_time_entries', 'description': 'Delete own time entries', 'category': 'time_entries'},
{'id': 7, 'name': 'delete_all_time_entries', 'description': 'Delete time entries from all users', 'category': 'time_entries'},
# Project Permissions (8-13)
{'id': 8, 'name': 'view_projects', 'description': 'View projects', 'category': 'projects'},
{'id': 9, 'name': 'create_projects', 'description': 'Create new projects', 'category': 'projects'},
{'id': 10, 'name': 'edit_projects', 'description': 'Edit project details', 'category': 'projects'},
{'id': 11, 'name': 'delete_projects', 'description': 'Delete projects', 'category': 'projects'},
{'id': 12, 'name': 'archive_projects', 'description': 'Archive/unarchive projects', 'category': 'projects'},
{'id': 13, 'name': 'manage_project_costs', 'description': 'Manage project costs and budgets', 'category': 'projects'},
# Task Permissions (14-21)
{'id': 14, 'name': 'view_own_tasks', 'description': 'View own tasks', 'category': 'tasks'},
{'id': 15, 'name': 'view_all_tasks', 'description': 'View all tasks', 'category': 'tasks'},
{'id': 16, 'name': 'create_tasks', 'description': 'Create tasks', 'category': 'tasks'},
{'id': 17, 'name': 'edit_own_tasks', 'description': 'Edit own tasks', 'category': 'tasks'},
{'id': 18, 'name': 'edit_all_tasks', 'description': 'Edit all tasks', 'category': 'tasks'},
{'id': 19, 'name': 'delete_own_tasks', 'description': 'Delete own tasks', 'category': 'tasks'},
{'id': 20, 'name': 'delete_all_tasks', 'description': 'Delete all tasks', 'category': 'tasks'},
{'id': 21, 'name': 'assign_tasks', 'description': 'Assign tasks to users', 'category': 'tasks'},
# Client Permissions (22-26)
{'id': 22, 'name': 'view_clients', 'description': 'View clients', 'category': 'clients'},
{'id': 23, 'name': 'create_clients', 'description': 'Create new clients', 'category': 'clients'},
{'id': 24, 'name': 'edit_clients', 'description': 'Edit client details', 'category': 'clients'},
{'id': 25, 'name': 'delete_clients', 'description': 'Delete clients', 'category': 'clients'},
{'id': 26, 'name': 'manage_client_notes', 'description': 'Manage client notes', 'category': 'clients'},
# Invoice Permissions (27-33)
{'id': 27, 'name': 'view_own_invoices', 'description': 'View own invoices', 'category': 'invoices'},
{'id': 28, 'name': 'view_all_invoices', 'description': 'View all invoices', 'category': 'invoices'},
{'id': 29, 'name': 'create_invoices', 'description': 'Create invoices', 'category': 'invoices'},
{'id': 30, 'name': 'edit_invoices', 'description': 'Edit invoices', 'category': 'invoices'},
{'id': 31, 'name': 'delete_invoices', 'description': 'Delete invoices', 'category': 'invoices'},
{'id': 32, 'name': 'send_invoices', 'description': 'Send invoices to clients', 'category': 'invoices'},
{'id': 33, 'name': 'manage_payments', 'description': 'Manage invoice payments', 'category': 'invoices'},
# Report Permissions (34-37)
{'id': 34, 'name': 'view_own_reports', 'description': 'View own reports', 'category': 'reports'},
{'id': 35, 'name': 'view_all_reports', 'description': 'View reports for all users', 'category': 'reports'},
{'id': 36, 'name': 'export_reports', 'description': 'Export reports to CSV/PDF', 'category': 'reports'},
{'id': 37, 'name': 'create_saved_reports', 'description': 'Create and save custom reports', 'category': 'reports'},
# User Management Permissions (38-42)
{'id': 38, 'name': 'view_users', 'description': 'View users list', 'category': 'users'},
{'id': 39, 'name': 'create_users', 'description': 'Create new users', 'category': 'users'},
{'id': 40, 'name': 'edit_users', 'description': 'Edit user details', 'category': 'users'},
{'id': 41, 'name': 'delete_users', 'description': 'Delete users', 'category': 'users'},
{'id': 42, 'name': 'manage_user_roles', 'description': 'Assign roles to users', 'category': 'users'},
# System Permissions (43-47)
{'id': 43, 'name': 'manage_settings', 'description': 'Manage system settings', 'category': 'system'},
{'id': 44, 'name': 'view_system_info', 'description': 'View system information', 'category': 'system'},
{'id': 45, 'name': 'manage_backups', 'description': 'Create and restore backups', 'category': 'system'},
{'id': 46, 'name': 'manage_telemetry', 'description': 'Manage telemetry settings', 'category': 'system'},
{'id': 47, 'name': 'view_audit_logs', 'description': 'View audit logs', 'category': 'system'},
# Administration Permissions (48-50)
{'id': 48, 'name': 'manage_roles', 'description': 'Create, edit, and delete roles', 'category': 'administration'},
{'id': 49, 'name': 'manage_permissions', 'description': 'Assign permissions to roles', 'category': 'administration'},
{'id': 50, 'name': 'view_permissions', 'description': 'View permissions and roles', 'category': 'administration'},
]
# Insert permissions
for perm in permissions_data:
perm['created_at'] = now
op.bulk_insert(permissions_table, permissions_data)
# Default roles data
roles_data = [
{'id': 1, 'name': 'super_admin', 'description': 'Super Administrator with full system access', 'is_system_role': True},
{'id': 2, 'name': 'admin', 'description': 'Administrator with most privileges', 'is_system_role': True},
{'id': 3, 'name': 'manager', 'description': 'Team Manager with oversight capabilities', 'is_system_role': True},
{'id': 4, 'name': 'user', 'description': 'Standard User', 'is_system_role': True},
{'id': 5, 'name': 'viewer', 'description': 'Read-only User', 'is_system_role': True},
]
# Insert roles
for role in roles_data:
role['created_at'] = now
role['updated_at'] = now
op.bulk_insert(roles_table, roles_data)
# Define role-permission mappings
role_permission_mappings = []
# Super Admin - All permissions (1-50)
for perm_id in range(1, 51):
role_permission_mappings.append({'role_id': 1, 'permission_id': perm_id, 'created_at': now})
# Admin - All except role/permission management (1-47)
for perm_id in range(1, 48):
role_permission_mappings.append({'role_id': 2, 'permission_id': perm_id, 'created_at': now})
# Manager - Oversight permissions
manager_perms = [2, 3, 4, 6, 8, 9, 10, 13, 15, 16, 18, 21, 22, 23, 24, 26, 28, 29, 30, 32, 35, 36, 37, 38]
for perm_id in manager_perms:
role_permission_mappings.append({'role_id': 3, 'permission_id': perm_id, 'created_at': now})
# User - Standard permissions
user_perms = [1, 3, 4, 6, 8, 14, 16, 17, 19, 22, 27, 34, 36]
for perm_id in user_perms:
role_permission_mappings.append({'role_id': 4, 'permission_id': perm_id, 'created_at': now})
# Viewer - Read-only permissions
viewer_perms = [1, 8, 14, 22, 27, 34]
for perm_id in viewer_perms:
role_permission_mappings.append({'role_id': 5, 'permission_id': perm_id, 'created_at': now})
# Insert role-permission mappings
op.bulk_insert(role_permissions_table, role_permission_mappings)
# Migrate existing users to new role system
# Get connection for executing queries
connection = op.get_bind()
# Find all users with role='admin' and assign them the 'admin' role
admin_users = connection.execute(sa.text("SELECT id FROM users WHERE role = 'admin'")).fetchall()
admin_role_assignments = [{'user_id': user[0], 'role_id': 2, 'assigned_at': now} for user in admin_users]
if admin_role_assignments:
op.bulk_insert(user_roles_table, admin_role_assignments)
# Find all users with role='user' and assign them the 'user' role
regular_users = connection.execute(sa.text("SELECT id FROM users WHERE role = 'user'")).fetchall()
user_role_assignments = [{'user_id': user[0], 'role_id': 4, 'assigned_at': now} for user in regular_users]
if user_role_assignments:
op.bulk_insert(user_roles_table, user_role_assignments)
def downgrade():
"""Remove permission system tables"""
op.drop_table('user_roles')
op.drop_table('role_permissions')
op.drop_index('idx_roles_name', table_name='roles')
op.drop_table('roles')
op.drop_index('idx_permissions_category', table_name='permissions')
op.drop_index('idx_permissions_name', table_name='permissions')
op.drop_table('permissions')

View File

@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='3.3.1',
version='3.4.0',
packages=find_packages(),
include_package_data=True,
install_requires=[

View File

@@ -0,0 +1,532 @@
"""
Test suite for ClientNote model.
Tests model creation, relationships, properties, and business logic.
"""
import pytest
from datetime import datetime
from app.models import ClientNote, Client, User
from app import db
# ============================================================================
# ClientNote Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
@pytest.mark.smoke
def test_client_note_creation(app, user, test_client):
"""Test basic client note creation."""
with app.app_context():
note = ClientNote(
content="Important note about the client",
user_id=user.id,
client_id=test_client.id,
is_important=False
)
db.session.add(note)
db.session.commit()
assert note.id is not None
assert note.content == "Important note about the client"
assert note.user_id == user.id
assert note.client_id == test_client.id
assert note.is_important is False
assert note.created_at is not None
assert note.updated_at is not None
@pytest.mark.unit
@pytest.mark.models
def test_client_note_requires_client(app, user):
"""Test that client note requires a client."""
with app.app_context():
with pytest.raises(ValueError, match="Note must be associated with a client"):
note = ClientNote(
content="Note without client",
user_id=user.id,
client_id=None
)
@pytest.mark.unit
@pytest.mark.models
def test_client_note_requires_content(app, user, test_client):
"""Test that client note requires content."""
with app.app_context():
with pytest.raises(ValueError, match="Note content cannot be empty"):
note = ClientNote(
content="",
user_id=user.id,
client_id=test_client.id
)
@pytest.mark.unit
@pytest.mark.models
def test_client_note_strips_content(app, user, test_client):
"""Test that client note content is stripped of whitespace."""
with app.app_context():
note = ClientNote(
content=" Note with spaces ",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
assert note.content == "Note with spaces"
@pytest.mark.unit
@pytest.mark.models
def test_client_note_author_relationship(app, user, test_client):
"""Test client note author relationship."""
with app.app_context():
note = ClientNote(
content="Test note",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
db.session.refresh(note)
assert note.author is not None
assert note.author.id == user.id
assert note.author.username == user.username
@pytest.mark.unit
@pytest.mark.models
def test_client_note_client_relationship(app, user, test_client):
"""Test client note client relationship."""
with app.app_context():
note = ClientNote(
content="Test note",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
db.session.refresh(note)
assert note.client is not None
assert note.client.id == test_client.id
assert note.client.name == test_client.name
@pytest.mark.unit
@pytest.mark.models
def test_client_has_notes_relationship(app, user, test_client):
"""Test that client has notes relationship."""
with app.app_context():
# Re-query the client to ensure it's in the current session
from app.models import Client
client = Client.query.get(test_client.id)
note1 = ClientNote(
content="First note",
user_id=user.id,
client_id=client.id
)
note2 = ClientNote(
content="Second note",
user_id=user.id,
client_id=client.id,
is_important=True
)
db.session.add_all([note1, note2])
db.session.commit()
db.session.refresh(client)
assert len(client.notes) == 2
@pytest.mark.unit
@pytest.mark.models
def test_client_note_author_name_property(app, user, test_client):
"""Test client note author_name property."""
with app.app_context():
# Ensure user has no full_name set (clean state)
user.full_name = None
db.session.commit()
# Test with username only
note = ClientNote(
content="Test note",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
db.session.refresh(note)
db.session.refresh(user)
assert note.author_name == user.username
# Test with full name
user.full_name = "Test User Full Name"
db.session.commit()
db.session.refresh(note)
assert note.author_name == "Test User Full Name"
@pytest.mark.unit
@pytest.mark.models
def test_client_note_client_name_property(app, user, test_client):
"""Test client note client_name property."""
with app.app_context():
note = ClientNote(
content="Test note",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
db.session.refresh(note)
assert note.client_name == test_client.name
@pytest.mark.unit
@pytest.mark.models
def test_client_note_can_edit(app, user, admin_user, test_client):
"""Test client note can_edit permission."""
with app.app_context():
note = ClientNote(
content="Test note",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
# Author can edit
assert note.can_edit(user) is True
# Admin can edit
assert note.can_edit(admin_user) is True
# Other user cannot edit
other_user = User(username='otheruser', role='user')
other_user.is_active = True
db.session.add(other_user)
db.session.commit()
assert note.can_edit(other_user) is False
@pytest.mark.unit
@pytest.mark.models
def test_client_note_can_delete(app, user, admin_user, test_client):
"""Test client note can_delete permission."""
with app.app_context():
note = ClientNote(
content="Test note",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
# Author can delete
assert note.can_delete(user) is True
# Admin can delete
assert note.can_delete(admin_user) is True
# Other user cannot delete
other_user = User(username='otheruser', role='user')
other_user.is_active = True
db.session.add(other_user)
db.session.commit()
assert note.can_delete(other_user) is False
@pytest.mark.unit
@pytest.mark.models
def test_client_note_edit_content(app, user, test_client):
"""Test editing client note content."""
with app.app_context():
note = ClientNote(
content="Original content",
user_id=user.id,
client_id=test_client.id,
is_important=False
)
db.session.add(note)
db.session.commit()
# Edit content
note.edit_content("Updated content", user, is_important=True)
db.session.commit()
assert note.content == "Updated content"
assert note.is_important is True
@pytest.mark.unit
@pytest.mark.models
def test_client_note_edit_content_permission_denied(app, user, test_client):
"""Test editing client note without permission."""
with app.app_context():
note = ClientNote(
content="Original content",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
# Create another user
other_user = User(username='otheruser', role='user')
other_user.is_active = True
db.session.add(other_user)
db.session.commit()
# Try to edit as other user
with pytest.raises(PermissionError, match="User does not have permission to edit this note"):
note.edit_content("Hacked content", other_user)
@pytest.mark.unit
@pytest.mark.models
def test_client_note_edit_content_empty_fails(app, user, test_client):
"""Test editing client note with empty content fails."""
with app.app_context():
note = ClientNote(
content="Original content",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
# Try to edit with empty content
with pytest.raises(ValueError, match="Note content cannot be empty"):
note.edit_content("", user)
@pytest.mark.unit
@pytest.mark.models
def test_client_note_to_dict(app, user, test_client):
"""Test client note serialization to dictionary."""
with app.app_context():
note = ClientNote(
content="Test note",
user_id=user.id,
client_id=test_client.id,
is_important=True
)
db.session.add(note)
db.session.commit()
db.session.refresh(note)
note_dict = note.to_dict()
assert 'id' in note_dict
assert 'content' in note_dict
assert 'client_id' in note_dict
assert 'client_name' in note_dict
assert 'user_id' in note_dict
assert 'author' in note_dict
assert 'author_name' in note_dict
assert 'is_important' in note_dict
assert 'created_at' in note_dict
assert 'updated_at' in note_dict
assert note_dict['content'] == "Test note"
assert note_dict['is_important'] is True
@pytest.mark.unit
@pytest.mark.models
def test_get_client_notes(app, user, test_client):
"""Test getting notes for a client."""
with app.app_context():
# Create multiple notes
note1 = ClientNote(
content="First note",
user_id=user.id,
client_id=test_client.id,
is_important=False
)
note2 = ClientNote(
content="Second note",
user_id=user.id,
client_id=test_client.id,
is_important=True
)
note3 = ClientNote(
content="Third note",
user_id=user.id,
client_id=test_client.id,
is_important=False
)
db.session.add_all([note1, note2, note3])
db.session.commit()
# Get all notes
notes = ClientNote.get_client_notes(test_client.id)
assert len(notes) == 3
# Get notes ordered by importance
notes_ordered = ClientNote.get_client_notes(test_client.id, order_by_important=True)
assert len(notes_ordered) == 3
# Important note should be first
assert notes_ordered[0].is_important is True
@pytest.mark.unit
@pytest.mark.models
def test_get_important_notes(app, user, test_client):
"""Test getting only important notes."""
with app.app_context():
# Create multiple notes
note1 = ClientNote(
content="Regular note",
user_id=user.id,
client_id=test_client.id,
is_important=False
)
note2 = ClientNote(
content="Important note 1",
user_id=user.id,
client_id=test_client.id,
is_important=True
)
note3 = ClientNote(
content="Important note 2",
user_id=user.id,
client_id=test_client.id,
is_important=True
)
db.session.add_all([note1, note2, note3])
db.session.commit()
# Get all important notes
important_notes = ClientNote.get_important_notes()
assert len(important_notes) == 2
assert all(note.is_important for note in important_notes)
# Get important notes for specific client
client_important = ClientNote.get_important_notes(client_id=test_client.id)
assert len(client_important) == 2
@pytest.mark.unit
@pytest.mark.models
def test_get_user_notes(app, user, test_client):
"""Test getting notes by a specific user."""
with app.app_context():
# Create notes by user
note1 = ClientNote(
content="User note 1",
user_id=user.id,
client_id=test_client.id
)
note2 = ClientNote(
content="User note 2",
user_id=user.id,
client_id=test_client.id
)
db.session.add_all([note1, note2])
# Create note by other user
other_user = User(username='otheruser', role='user')
other_user.is_active = True
db.session.add(other_user)
db.session.commit()
note3 = ClientNote(
content="Other user note",
user_id=other_user.id,
client_id=test_client.id
)
db.session.add(note3)
db.session.commit()
# Get notes by specific user
user_notes = ClientNote.get_user_notes(user.id)
assert len(user_notes) == 2
assert all(note.user_id == user.id for note in user_notes)
# Test with limit
limited_notes = ClientNote.get_user_notes(user.id, limit=1)
assert len(limited_notes) == 1
@pytest.mark.unit
@pytest.mark.models
def test_get_recent_notes(app, user, test_client):
"""Test getting recent notes across all clients."""
with app.app_context():
# Create multiple notes
for i in range(15):
note = ClientNote(
content=f"Note {i}",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
# Get recent notes with default limit
recent_notes = ClientNote.get_recent_notes()
assert len(recent_notes) == 10
# Get recent notes with custom limit
recent_notes_5 = ClientNote.get_recent_notes(limit=5)
assert len(recent_notes_5) == 5
@pytest.mark.unit
@pytest.mark.models
def test_client_note_repr(app, user, test_client):
"""Test client note string representation."""
with app.app_context():
note = ClientNote(
content="Test note",
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
db.session.refresh(note)
repr_str = repr(note)
assert 'ClientNote' in repr_str
assert user.username in repr_str
assert str(test_client.id) in repr_str
@pytest.mark.unit
@pytest.mark.models
def test_client_note_cascade_delete(app, user, test_client):
"""Test that notes are deleted when client is deleted."""
with app.app_context():
# Re-query the client to ensure it's in the current session
from app.models import Client
client = Client.query.get(test_client.id)
note = ClientNote(
content="Test note",
user_id=user.id,
client_id=client.id
)
db.session.add(note)
db.session.commit()
note_id = note.id
# Delete client
db.session.delete(client)
db.session.commit()
# Note should be deleted
deleted_note = ClientNote.query.get(note_id)
assert deleted_note is None

View File

@@ -0,0 +1,507 @@
"""
Test suite for client notes routes and endpoints.
Tests all client note CRUD operations and API endpoints.
"""
import pytest
import json
from app.models import ClientNote
from app import db
# ============================================================================
# Client Notes Routes Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.routes
@pytest.mark.smoke
def test_create_client_note(authenticated_client, test_client, user, app):
"""Test creating a client note."""
with app.app_context():
response = authenticated_client.post(
f'/clients/{test_client.id}/notes/create',
data={
'content': 'This is a test note',
'is_important': 'false'
},
follow_redirects=False
)
# Should redirect back to client view
assert response.status_code == 302
assert f'/clients/{test_client.id}' in response.location
# Verify note was created
note = ClientNote.query.filter_by(client_id=test_client.id).first()
assert note is not None
assert note.content == 'This is a test note'
assert note.is_important is False
@pytest.mark.integration
@pytest.mark.routes
def test_create_important_client_note(authenticated_client, test_client, user, app):
"""Test creating an important client note."""
with app.app_context():
response = authenticated_client.post(
f'/clients/{test_client.id}/notes/create',
data={
'content': 'Important note',
'is_important': 'true'
},
follow_redirects=False
)
assert response.status_code == 302
# Verify note was created with important flag
note = ClientNote.query.filter_by(client_id=test_client.id).first()
assert note is not None
assert note.content == 'Important note'
assert note.is_important is True
@pytest.mark.integration
@pytest.mark.routes
def test_create_note_empty_content_fails(authenticated_client, test_client, app):
"""Test that creating a note with empty content fails."""
with app.app_context():
response = authenticated_client.post(
f'/clients/{test_client.id}/notes/create',
data={
'content': '',
'is_important': 'false'
},
follow_redirects=True
)
# Should show error and redirect back
assert response.status_code == 200
# Verify no note was created
note_count = ClientNote.query.filter_by(client_id=test_client.id).count()
assert note_count == 0
@pytest.mark.integration
@pytest.mark.routes
def test_create_note_invalid_client_fails(authenticated_client, app):
"""Test that creating a note for non-existent client fails."""
with app.app_context():
response = authenticated_client.post(
'/clients/99999/notes/create',
data={
'content': 'Test note',
'is_important': 'false'
},
follow_redirects=False
)
# Should return 404
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.routes
def test_edit_client_note_page(authenticated_client, test_client, user, app):
"""Test accessing the edit client note page."""
with app.app_context():
# Create a note
note = ClientNote(
content='Original note',
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
note_id = note.id
# Access edit page
response = authenticated_client.get(
f'/clients/{test_client.id}/notes/{note_id}/edit'
)
assert response.status_code == 200
assert b'Edit Client Note' in response.data or b'edit' in response.data.lower()
@pytest.mark.integration
@pytest.mark.routes
def test_edit_client_note_submit(authenticated_client, test_client, user, app):
"""Test editing a client note."""
with app.app_context():
# Create a note
note = ClientNote(
content='Original note',
user_id=user.id,
client_id=test_client.id,
is_important=False
)
db.session.add(note)
db.session.commit()
note_id = note.id
# Edit the note
response = authenticated_client.post(
f'/clients/{test_client.id}/notes/{note_id}/edit',
data={
'content': 'Updated note content',
'is_important': 'true'
},
follow_redirects=False
)
assert response.status_code == 302
assert f'/clients/{test_client.id}' in response.location
# Verify note was updated
updated_note = ClientNote.query.get(note_id)
assert updated_note.content == 'Updated note content'
assert updated_note.is_important is True
@pytest.mark.integration
@pytest.mark.routes
def test_edit_note_permission_denied(authenticated_client, test_client, user, admin_user, app):
"""Test that users cannot edit notes they don't own (unless admin)."""
with app.app_context():
# Create a note by admin
note = ClientNote(
content='Admin note',
user_id=admin_user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
note_id = note.id
# Regular user tries to edit (should fail if not the owner)
# This test assumes the route checks permissions
response = authenticated_client.post(
f'/clients/{test_client.id}/notes/{note_id}/edit',
data={
'content': 'Hacked content'
},
follow_redirects=True
)
# Note: This may pass if the authenticated_client is an admin
# For a proper test, we'd need a fixture for a non-admin authenticated client
@pytest.mark.integration
@pytest.mark.routes
def test_delete_client_note(authenticated_client, test_client, user, app):
"""Test deleting a client note."""
with app.app_context():
# Create a note
note = ClientNote(
content='Note to delete',
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
note_id = note.id
# Delete the note
response = authenticated_client.post(
f'/clients/{test_client.id}/notes/{note_id}/delete',
follow_redirects=False
)
assert response.status_code == 302
assert f'/clients/{test_client.id}' in response.location
# Verify note was deleted
deleted_note = ClientNote.query.get(note_id)
assert deleted_note is None
@pytest.mark.integration
@pytest.mark.routes
def test_delete_nonexistent_note_fails(authenticated_client, test_client, app):
"""Test that deleting a non-existent note fails."""
with app.app_context():
response = authenticated_client.post(
f'/clients/{test_client.id}/notes/99999/delete',
follow_redirects=False
)
# Should return 404
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.routes
@pytest.mark.api
def test_toggle_important_note(authenticated_client, test_client, user, app):
"""Test toggling the important flag on a note."""
with app.app_context():
# Create a note
note = ClientNote(
content='Test note',
user_id=user.id,
client_id=test_client.id,
is_important=False
)
db.session.add(note)
db.session.commit()
note_id = note.id
# Toggle to important
response = authenticated_client.post(
f'/clients/{test_client.id}/notes/{note_id}/toggle-important',
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert data['is_important'] is True
# Verify in database
updated_note = ClientNote.query.get(note_id)
assert updated_note.is_important is True
# Toggle back to not important
response = authenticated_client.post(
f'/clients/{test_client.id}/notes/{note_id}/toggle-important',
content_type='application/json'
)
assert response.status_code == 200
data = response.get_json()
assert data['is_important'] is False
# ============================================================================
# Client Notes API Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.routes
@pytest.mark.api
def test_list_client_notes_api(authenticated_client, test_client, user, app):
"""Test getting all notes for a client via API."""
with app.app_context():
# Create multiple notes
note1 = ClientNote(
content='First note',
user_id=user.id,
client_id=test_client.id,
is_important=False
)
note2 = ClientNote(
content='Second note',
user_id=user.id,
client_id=test_client.id,
is_important=True
)
db.session.add_all([note1, note2])
db.session.commit()
# Get notes via API
response = authenticated_client.get(
f'/api/clients/{test_client.id}/notes'
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert len(data['notes']) == 2
@pytest.mark.integration
@pytest.mark.routes
@pytest.mark.api
def test_list_client_notes_api_ordered_by_important(authenticated_client, test_client, user, app):
"""Test getting notes ordered by importance via API."""
with app.app_context():
# Create multiple notes
note1 = ClientNote(
content='Regular note',
user_id=user.id,
client_id=test_client.id,
is_important=False
)
note2 = ClientNote(
content='Important note',
user_id=user.id,
client_id=test_client.id,
is_important=True
)
db.session.add_all([note1, note2])
db.session.commit()
# Get notes ordered by importance
response = authenticated_client.get(
f'/api/clients/{test_client.id}/notes?order_by_important=true'
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# First note should be the important one
assert data['notes'][0]['is_important'] is True
@pytest.mark.integration
@pytest.mark.routes
@pytest.mark.api
def test_get_single_note_api(authenticated_client, test_client, user, app):
"""Test getting a single note via API."""
with app.app_context():
# Create a note
note = ClientNote(
content='Test note',
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
note_id = note.id
# Get note via API
response = authenticated_client.get(
f'/api/client-notes/{note_id}'
)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert data['note']['id'] == note_id
assert data['note']['content'] == 'Test note'
@pytest.mark.integration
@pytest.mark.routes
@pytest.mark.api
def test_get_important_notes_api(authenticated_client, test_client, user, app):
"""Test getting all important notes via API."""
with app.app_context():
# Create notes
note1 = ClientNote(
content='Regular note',
user_id=user.id,
client_id=test_client.id,
is_important=False
)
note2 = ClientNote(
content='Important note 1',
user_id=user.id,
client_id=test_client.id,
is_important=True
)
note3 = ClientNote(
content='Important note 2',
user_id=user.id,
client_id=test_client.id,
is_important=True
)
db.session.add_all([note1, note2, note3])
db.session.commit()
# Get important notes
response = authenticated_client.get('/api/client-notes/important')
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert len(data['notes']) == 2
assert all(note['is_important'] for note in data['notes'])
@pytest.mark.integration
@pytest.mark.routes
@pytest.mark.api
def test_get_recent_notes_api(authenticated_client, test_client, user, app):
"""Test getting recent notes via API."""
with app.app_context():
# Create multiple notes
for i in range(5):
note = ClientNote(
content=f'Note {i}',
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
# Get recent notes with limit
response = authenticated_client.get('/api/client-notes/recent?limit=3')
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert len(data['notes']) == 3
@pytest.mark.integration
@pytest.mark.routes
@pytest.mark.api
def test_get_user_notes_api(authenticated_client, test_client, user, app):
"""Test getting notes by a specific user via API."""
with app.app_context():
# Create notes by user
for i in range(3):
note = ClientNote(
content=f'User note {i}',
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
# Get user's notes
response = authenticated_client.get(f'/api/client-notes/user/{user.id}')
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
assert len(data['notes']) == 3
# ============================================================================
# Client View Integration Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.routes
def test_client_view_shows_notes(authenticated_client, test_client, user, app):
"""Test that client view page shows notes."""
with app.app_context():
# Create a note
note = ClientNote(
content='Visible note',
user_id=user.id,
client_id=test_client.id
)
db.session.add(note)
db.session.commit()
# View client page
response = authenticated_client.get(f'/clients/{test_client.id}')
assert response.status_code == 200
# Check that notes section is present
assert b'Internal Notes' in response.data or b'notes' in response.data.lower()
@pytest.mark.integration
@pytest.mark.routes
def test_unauthenticated_user_cannot_access_notes(client, test_client, app):
"""Test that unauthenticated users cannot access note routes."""
with app.app_context():
# Try to create a note
response = client.post(
f'/clients/{test_client.id}/notes/create',
data={'content': 'Unauthorized note'},
follow_redirects=False
)
# Should redirect to login
assert response.status_code == 302
assert 'login' in response.location.lower()

716
tests/test_expenses.py Normal file
View File

@@ -0,0 +1,716 @@
"""
Comprehensive tests for Expense model and related functionality.
This module tests:
- Expense model creation and validation
- Relationships with User, Project, Client, and Invoice models
- Query methods (get_expenses, get_total_expenses, etc.)
- Approval and reimbursement workflows
- Data integrity and constraints
"""
import pytest
from datetime import date, datetime, timedelta
from decimal import Decimal
from app import create_app, db
from app.models import User, Project, Client, Invoice, Expense
@pytest.fixture
def app():
"""Create and configure a test application instance."""
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'WTF_CSRF_ENABLED': False
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client_fixture(app):
"""Create a test Flask client."""
return app.test_client()
@pytest.fixture
def test_user(app):
"""Create a test user."""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
db.session.commit()
return user.id
@pytest.fixture
def test_admin(app):
"""Create a test admin user."""
with app.app_context():
admin = User(username='admin', role='admin')
db.session.add(admin)
db.session.commit()
return admin.id
@pytest.fixture
def test_client(app):
"""Create a test client."""
with app.app_context():
client = Client(name='Test Client', description='A test client')
db.session.add(client)
db.session.commit()
return client.id
@pytest.fixture
def test_project(app, test_client):
"""Create a test project."""
with app.app_context():
project = Project(
name='Test Project',
client_id=test_client,
description='A test project',
billable=True,
hourly_rate=Decimal('100.00')
)
db.session.add(project)
db.session.commit()
return project.id
@pytest.fixture
def test_invoice(app, test_client, test_project, test_user):
"""Create a test invoice."""
with app.app_context():
client = db.session.get(Client, test_client)
invoice = Invoice(
invoice_number='INV-TEST-001',
project_id=test_project,
client_name=client.name,
due_date=date.today() + timedelta(days=30),
created_by=test_user,
client_id=test_client,
issue_date=date.today(),
status='draft'
)
db.session.add(invoice)
db.session.commit()
return invoice.id
# Model Tests
class TestExpenseModel:
"""Test Expense model creation, validation, and basic operations."""
def test_create_expense(self, app, test_user):
"""Test creating a basic expense."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Travel Expense',
category='travel',
amount=Decimal('150.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.id is not None
assert expense.title == 'Travel Expense'
assert expense.category == 'travel'
assert expense.amount == Decimal('150.00')
assert expense.currency_code == 'EUR'
assert expense.status == 'pending'
assert expense.billable is False
assert expense.reimbursable is True
def test_create_expense_with_all_fields(self, app, test_user, test_project, test_client):
"""Test creating an expense with all optional fields."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Conference Travel',
category='travel',
amount=Decimal('500.00'),
expense_date=date.today(),
description='Flight and hotel for conference',
project_id=test_project,
client_id=test_client,
currency_code='USD',
tax_amount=Decimal('50.00'),
payment_method='credit_card',
payment_date=date.today(),
vendor='Airline Inc',
receipt_number='REC-2024-001',
notes='Business class flight',
tags='conference,travel,urgent',
billable=True,
reimbursable=True
)
db.session.add(expense)
db.session.commit()
assert expense.description == 'Flight and hotel for conference'
assert expense.project_id == test_project
assert expense.client_id == test_client
assert expense.currency_code == 'USD'
assert expense.tax_amount == Decimal('50.00')
assert expense.vendor == 'Airline Inc'
assert expense.billable is True
def test_expense_str_representation(self, app, test_user):
"""Test __repr__ method."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Office Supplies',
category='supplies',
amount=Decimal('75.50'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert 'Office Supplies' in str(expense)
assert 'EUR' in str(expense)
def test_expense_timestamps(self, app, test_user):
"""Test automatic timestamp creation."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='other',
amount=Decimal('10.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.created_at is not None
assert expense.updated_at is not None
assert isinstance(expense.created_at, datetime)
assert isinstance(expense.updated_at, datetime)
class TestExpenseProperties:
"""Test Expense computed properties."""
def test_total_amount_property(self, app, test_user):
"""Test total_amount property including tax."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
tax_amount=Decimal('10.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.total_amount == Decimal('110.00')
def test_tag_list_property(self, app, test_user):
"""Test tag_list property parsing."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
tags='urgent, client-meeting, conference'
)
db.session.add(expense)
db.session.commit()
tags = expense.tag_list
assert len(tags) == 3
assert 'urgent' in tags
assert 'client-meeting' in tags
assert 'conference' in tags
def test_is_approved_property(self, app, test_user, test_admin):
"""Test is_approved property."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
# Initially not approved
assert expense.is_approved is False
# Approve
expense.approve(test_admin)
db.session.commit()
assert expense.is_approved is True
def test_is_reimbursed_property(self, app, test_user):
"""Test is_reimbursed property."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.is_reimbursed is False
expense.mark_as_reimbursed()
db.session.commit()
assert expense.is_reimbursed is True
class TestExpenseRelationships:
"""Test Expense relationships with other models."""
def test_user_relationship(self, app, test_user):
"""Test relationship with User model."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
expense = db.session.get(Expense, expense.id)
user = db.session.get(User, test_user)
assert expense.user is not None
assert expense.user.id == test_user
assert expense in user.expenses.all()
def test_project_relationship(self, app, test_user, test_project):
"""Test relationship with Project model."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
project_id=test_project
)
db.session.add(expense)
db.session.commit()
expense = db.session.get(Expense, expense.id)
project = db.session.get(Project, test_project)
assert expense.project is not None
assert expense.project.id == test_project
assert expense in project.expenses.all()
def test_client_relationship(self, app, test_user, test_client):
"""Test relationship with Client model."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
client_id=test_client
)
db.session.add(expense)
db.session.commit()
expense = db.session.get(Expense, expense.id)
client = db.session.get(Client, test_client)
assert expense.client is not None
assert expense.client.id == test_client
assert expense in client.expenses.all()
class TestExpenseMethods:
"""Test Expense instance and class methods."""
def test_approve_method(self, app, test_user, test_admin):
"""Test approving an expense."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
expense.approve(test_admin, notes='Approved for reimbursement')
db.session.commit()
assert expense.status == 'approved'
assert expense.approved_by == test_admin
assert expense.approved_at is not None
def test_reject_method(self, app, test_user, test_admin):
"""Test rejecting an expense."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
expense.reject(test_admin, 'Receipt not provided')
db.session.commit()
assert expense.status == 'rejected'
assert expense.approved_by == test_admin
assert expense.rejection_reason == 'Receipt not provided'
def test_mark_as_reimbursed(self, app, test_user, test_admin):
"""Test marking expense as reimbursed."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
# Approve first
expense.approve(test_admin)
db.session.commit()
# Mark as reimbursed
expense.mark_as_reimbursed()
db.session.commit()
assert expense.reimbursed is True
assert expense.reimbursed_at is not None
assert expense.status == 'reimbursed'
def test_mark_as_invoiced(self, app, test_user, test_invoice):
"""Test marking expense as invoiced."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
billable=True
)
db.session.add(expense)
db.session.commit()
expense.mark_as_invoiced(test_invoice)
db.session.commit()
assert expense.invoiced is True
assert expense.invoice_id == test_invoice
def test_to_dict(self, app, test_user):
"""Test converting expense to dictionary."""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
tax_amount=Decimal('10.00'),
expense_date=date.today(),
description='Test description'
)
db.session.add(expense)
db.session.commit()
expense = db.session.get(Expense, expense.id)
expense_dict = expense.to_dict()
assert expense_dict['id'] == expense.id
assert expense_dict['user_id'] == test_user
assert expense_dict['title'] == 'Test Expense'
assert expense_dict['category'] == 'travel'
assert expense_dict['amount'] == 100.00
assert expense_dict['tax_amount'] == 10.00
assert expense_dict['total_amount'] == 110.00
assert 'created_at' in expense_dict
class TestExpenseQueries:
"""Test Expense query class methods."""
def test_get_expenses(self, app, test_user):
"""Test retrieving expenses."""
with app.app_context():
expenses = [
Expense(
user_id=test_user,
title=f'Expense {i}',
category='travel',
amount=Decimal(f'{100 + i * 10}.00'),
expense_date=date.today() - timedelta(days=i)
)
for i in range(5)
]
db.session.add_all(expenses)
db.session.commit()
retrieved = Expense.get_expenses(user_id=test_user)
assert len(retrieved) == 5
# Should be ordered by expense_date desc
assert retrieved[0].title == 'Expense 0'
def test_get_expenses_by_status(self, app, test_user, test_admin):
"""Test filtering expenses by status."""
with app.app_context():
# Create expenses with different statuses
exp1 = Expense(
user_id=test_user,
title='Pending Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
exp2 = Expense(
user_id=test_user,
title='Approved Expense',
category='travel',
amount=Decimal('200.00'),
expense_date=date.today()
)
db.session.add_all([exp1, exp2])
db.session.commit()
exp2.approve(test_admin)
db.session.commit()
pending = Expense.get_expenses(user_id=test_user, status='pending')
assert len(pending) == 1
assert pending[0].title == 'Pending Expense'
approved = Expense.get_expenses(user_id=test_user, status='approved')
assert len(approved) == 1
assert approved[0].title == 'Approved Expense'
def test_get_total_expenses(self, app, test_user):
"""Test calculating total expenses."""
with app.app_context():
amounts = [Decimal('100.00'), Decimal('250.50'), Decimal('75.25')]
taxes = [Decimal('10.00'), Decimal('25.00'), Decimal('7.50')]
expenses = [
Expense(
user_id=test_user,
title=f'Expense {i}',
category='travel',
amount=amount,
tax_amount=tax,
expense_date=date.today()
)
for i, (amount, tax) in enumerate(zip(amounts, taxes))
]
db.session.add_all(expenses)
db.session.commit()
total = Expense.get_total_expenses(user_id=test_user, include_tax=True)
expected = sum(amounts) + sum(taxes)
assert abs(total - float(expected)) < 0.01
def test_get_expenses_by_category(self, app, test_user):
"""Test grouping expenses by category."""
with app.app_context():
categories = ['travel', 'travel', 'meals', 'supplies', 'meals']
amounts = [Decimal('100.00'), Decimal('150.00'), Decimal('50.00'),
Decimal('75.00'), Decimal('60.00')]
expenses = [
Expense(
user_id=test_user,
title=f'Expense {i}',
category=category,
amount=amount,
expense_date=date.today()
)
for i, (category, amount) in enumerate(zip(categories, amounts))
]
db.session.add_all(expenses)
db.session.commit()
by_category = Expense.get_expenses_by_category(user_id=test_user)
assert len(by_category) == 3
travel = next(c for c in by_category if c['category'] == 'travel')
assert travel['count'] == 2
assert abs(travel['total_amount'] - 250.00) < 0.01
def test_get_pending_approvals(self, app, test_user):
"""Test retrieving pending expenses."""
with app.app_context():
exp1 = Expense(
user_id=test_user,
title='Pending 1',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
status='pending'
)
exp2 = Expense(
user_id=test_user,
title='Pending 2',
category='travel',
amount=Decimal('200.00'),
expense_date=date.today(),
status='pending'
)
db.session.add_all([exp1, exp2])
db.session.commit()
pending = Expense.get_pending_approvals(user_id=test_user)
assert len(pending) == 2
def test_get_uninvoiced_expenses(self, app, test_user, test_admin, test_project):
"""Test retrieving uninvoiced billable expenses."""
with app.app_context():
exp1 = Expense(
user_id=test_user,
title='Billable Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today(),
billable=True,
project_id=test_project
)
exp2 = Expense(
user_id=test_user,
title='Non-billable Expense',
category='travel',
amount=Decimal('200.00'),
expense_date=date.today(),
billable=False,
project_id=test_project
)
db.session.add_all([exp1, exp2])
db.session.commit()
# Approve both
exp1.approve(test_admin)
exp2.approve(test_admin)
db.session.commit()
uninvoiced = Expense.get_uninvoiced_expenses(project_id=test_project)
assert len(uninvoiced) == 1
assert uninvoiced[0].title == 'Billable Expense'
class TestExpenseConstraints:
"""Test database constraints and data integrity."""
def test_cannot_create_expense_without_user(self, app):
"""Test that user_id is required."""
with app.app_context():
expense = Expense(
user_id=None,
title='Test Expense',
category='travel',
amount=Decimal('100.00'),
expense_date=date.today()
)
db.session.add(expense)
with pytest.raises(Exception):
db.session.commit()
db.session.rollback()
# Smoke Tests
class TestExpenseSmokeTests:
"""Basic smoke tests to ensure Expense functionality works."""
def test_expense_creation_smoke(self, app, test_user):
"""Smoke test: Can we create an expense?"""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Smoke Test Expense',
category='travel',
amount=Decimal('99.99'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
assert expense.id is not None
def test_expense_query_smoke(self, app, test_user):
"""Smoke test: Can we query expenses?"""
with app.app_context():
expense = Expense(
user_id=test_user,
title='Query Smoke Test',
category='travel',
amount=Decimal('200.00'),
expense_date=date.today()
)
db.session.add(expense)
db.session.commit()
expenses = Expense.query.filter_by(user_id=test_user).all()
assert len(expenses) > 0
def test_expense_workflow_smoke(self, app, test_user, test_admin):
"""Smoke test: Does the full approval workflow work?"""
with app.app_context():
# Create expense
expense = Expense(
user_id=test_user,
title='Workflow Test',
category='travel',
amount=Decimal('500.00'),
expense_date=date.today(),
reimbursable=True
)
db.session.add(expense)
db.session.commit()
# Approve
expense.approve(test_admin)
db.session.commit()
assert expense.status == 'approved'
# Reimburse
expense.mark_as_reimbursed()
db.session.commit()
assert expense.status == 'reimbursed'

View File

@@ -2,7 +2,7 @@ import pytest
from datetime import datetime, date, timedelta
from decimal import Decimal
from app import db
from app.models import User, Project, Invoice, InvoiceItem, Settings, Client
from app.models import User, Project, Invoice, InvoiceItem, Settings, Client, ExtraGood
@pytest.fixture
def sample_user(app):
@@ -626,4 +626,292 @@ def test_invoice_to_dict_includes_payment_fields(app, sample_invoice):
assert invoice_dict['payment_method'] == 'paypal'
assert invoice_dict['payment_reference'] == 'PP-123'
assert invoice_dict['payment_notes'] == 'PayPal payment'
assert invoice_dict['amount_paid'] == 500.00
assert invoice_dict['amount_paid'] == 500.00
# ===============================================
# Extra Goods PDF Export Tests
# ===============================================
@pytest.mark.unit
@pytest.mark.invoices
def test_invoice_with_extra_goods(app, sample_invoice, sample_user):
"""Test that invoices can have extra goods associated."""
# Create an extra good
good = ExtraGood(
name='Software License',
description='Annual software license',
category='license',
quantity=Decimal('1.00'),
unit_price=Decimal('299.99'),
sku='LIC-2024-001',
created_by=sample_user.id,
invoice_id=sample_invoice.id
)
db.session.add(good)
db.session.commit()
# Verify the good is associated with the invoice
assert len(list(sample_invoice.extra_goods)) == 1
assert sample_invoice.extra_goods[0].name == 'Software License'
assert sample_invoice.extra_goods[0].category == 'license'
assert sample_invoice.extra_goods[0].sku == 'LIC-2024-001'
@pytest.mark.unit
@pytest.mark.invoices
def test_pdf_generator_includes_extra_goods(app, sample_invoice, sample_user):
"""Test that PDF generator includes extra goods in the output."""
from app.utils.pdf_generator import InvoicePDFGenerator
# Add an invoice item
item = InvoiceItem(
invoice_id=sample_invoice.id,
description='Development work',
quantity=Decimal('10.00'),
unit_price=Decimal('75.00')
)
db.session.add(item)
# Add an extra good
good = ExtraGood(
name='Hardware Component',
description='Raspberry Pi 4 Model B',
category='product',
quantity=Decimal('2.00'),
unit_price=Decimal('55.00'),
sku='RPI4-4GB',
created_by=sample_user.id,
invoice_id=sample_invoice.id
)
db.session.add(good)
db.session.commit()
# Calculate totals
sample_invoice.calculate_totals()
db.session.commit()
# Generate PDF
generator = InvoicePDFGenerator(sample_invoice)
html_content = generator._generate_html()
# Verify invoice item is in HTML
assert 'Development work' in html_content
# Verify extra good is in HTML
assert 'Hardware Component' in html_content
assert 'Raspberry Pi 4 Model B' in html_content
assert 'RPI4-4GB' in html_content
assert 'Product' in html_content or 'product' in html_content
@pytest.mark.unit
@pytest.mark.invoices
def test_pdf_generator_extra_goods_formatting(app, sample_invoice, sample_user):
"""Test that extra goods are properly formatted in PDF."""
from app.utils.pdf_generator import InvoicePDFGenerator
# Add extra goods with various attributes
goods = [
ExtraGood(
name='Product A',
description='Description A',
category='product',
quantity=Decimal('1.00'),
unit_price=Decimal('100.00'),
sku='PROD-A',
created_by=sample_user.id,
invoice_id=sample_invoice.id
),
ExtraGood(
name='Service B',
description='Description B',
category='service',
quantity=Decimal('5.00'),
unit_price=Decimal('50.00'),
sku='SRV-B',
created_by=sample_user.id,
invoice_id=sample_invoice.id
),
ExtraGood(
name='Material C',
category='material',
quantity=Decimal('10.00'),
unit_price=Decimal('25.00'),
created_by=sample_user.id,
invoice_id=sample_invoice.id
)
]
for good in goods:
db.session.add(good)
db.session.commit()
# Calculate totals
sample_invoice.calculate_totals()
db.session.commit()
# Generate PDF
generator = InvoicePDFGenerator(sample_invoice)
html_content = generator._generate_html()
# Verify all goods are present
assert 'Product A' in html_content
assert 'Service B' in html_content
assert 'Material C' in html_content
# Verify quantities and prices
assert '1.00' in html_content # Product A quantity
assert '5.00' in html_content # Service B quantity
assert '10.00' in html_content # Material C quantity
@pytest.mark.unit
@pytest.mark.invoices
def test_pdf_fallback_generator_includes_extra_goods(app, sample_invoice, sample_user):
"""Test that fallback PDF generator includes extra goods."""
from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback
# Add an invoice item
item = InvoiceItem(
invoice_id=sample_invoice.id,
description='Consulting Services',
quantity=Decimal('8.00'),
unit_price=Decimal('100.00')
)
db.session.add(item)
# Add extra goods
good = ExtraGood(
name='Training Materials',
description='Printed training manuals',
category='material',
quantity=Decimal('20.00'),
unit_price=Decimal('15.00'),
sku='TRN-MAN-001',
created_by=sample_user.id,
invoice_id=sample_invoice.id
)
db.session.add(good)
db.session.commit()
# Calculate totals
sample_invoice.calculate_totals()
db.session.commit()
# Generate PDF using fallback generator
generator = InvoicePDFGeneratorFallback(sample_invoice)
story = generator._build_story()
# Verify story is not empty
assert len(story) > 0
# Note: We can't easily verify the content of the ReportLab story
# but we can ensure it doesn't crash with extra goods
@pytest.mark.smoke
@pytest.mark.invoices
def test_pdf_export_with_extra_goods_smoke(app, sample_invoice, sample_user):
"""Smoke test: Generate PDF with extra goods without errors."""
from app.utils.pdf_generator import InvoicePDFGenerator
# Add multiple items and goods
item = InvoiceItem(
invoice_id=sample_invoice.id,
description='Web Development',
quantity=Decimal('40.00'),
unit_price=Decimal('85.00')
)
db.session.add(item)
goods = [
ExtraGood(
name='Domain Registration',
description='Annual domain .com',
category='service',
quantity=Decimal('1.00'),
unit_price=Decimal('12.99'),
sku='DOM-REG-001',
created_by=sample_user.id,
invoice_id=sample_invoice.id
),
ExtraGood(
name='SSL Certificate',
description='Wildcard SSL cert',
category='service',
quantity=Decimal('1.00'),
unit_price=Decimal('89.00'),
sku='SSL-WILD-001',
created_by=sample_user.id,
invoice_id=sample_invoice.id
),
ExtraGood(
name='Server Credits',
category='service',
quantity=Decimal('12.00'),
unit_price=Decimal('50.00'),
created_by=sample_user.id,
invoice_id=sample_invoice.id
)
]
for good in goods:
db.session.add(good)
db.session.commit()
# Calculate totals
sample_invoice.calculate_totals()
db.session.commit()
# Generate PDF - should not raise any exceptions
generator = InvoicePDFGenerator(sample_invoice)
pdf_bytes = generator.generate_pdf()
# Verify PDF was generated
assert pdf_bytes is not None
assert len(pdf_bytes) > 0
assert pdf_bytes[:4] == b'%PDF' # PDF magic number
@pytest.mark.smoke
@pytest.mark.invoices
def test_pdf_export_fallback_with_extra_goods_smoke(app, sample_invoice, sample_user):
"""Smoke test: Generate fallback PDF with extra goods without errors."""
from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback
# Add items and goods
item = InvoiceItem(
invoice_id=sample_invoice.id,
description='Design Services',
quantity=Decimal('20.00'),
unit_price=Decimal('65.00')
)
db.session.add(item)
good = ExtraGood(
name='Stock Photos',
description='Premium stock photo bundle',
category='material',
quantity=Decimal('1.00'),
unit_price=Decimal('199.00'),
sku='STOCK-BUNDLE-PRO',
created_by=sample_user.id,
invoice_id=sample_invoice.id
)
db.session.add(good)
db.session.commit()
# Calculate totals
sample_invoice.calculate_totals()
db.session.commit()
# Generate PDF using fallback - should not raise any exceptions
generator = InvoicePDFGeneratorFallback(sample_invoice)
pdf_bytes = generator.generate_pdf()
# Verify PDF was generated
assert pdf_bytes is not None
assert len(pdf_bytes) > 0
assert pdf_bytes[:4] == b'%PDF' # PDF magic number

409
tests/test_permissions.py Normal file
View File

@@ -0,0 +1,409 @@
"""Tests for the advanced permission system"""
import pytest
from app import db
from app.models import User, Permission, Role
@pytest.mark.unit
@pytest.mark.models
def test_permission_creation(app):
"""Test permission creation"""
with app.app_context():
permission = Permission(
name='test_permission',
description='Test permission',
category='testing'
)
db.session.add(permission)
db.session.commit()
assert permission.id is not None
assert permission.name == 'test_permission'
assert permission.description == 'Test permission'
assert permission.category == 'testing'
@pytest.mark.unit
@pytest.mark.models
def test_role_creation(app):
"""Test role creation"""
with app.app_context():
role = Role(
name='test_role',
description='Test role',
is_system_role=False
)
db.session.add(role)
db.session.commit()
assert role.id is not None
assert role.name == 'test_role'
assert role.description == 'Test role'
assert role.is_system_role is False
@pytest.mark.unit
@pytest.mark.models
def test_role_permission_assignment(app):
"""Test assigning permissions to a role"""
with app.app_context():
# Create permission
permission1 = Permission(name='perm1', category='test')
permission2 = Permission(name='perm2', category='test')
db.session.add_all([permission1, permission2])
# Create role
role = Role(name='test_role')
db.session.add(role)
db.session.commit()
# Assign permissions
role.add_permission(permission1)
role.add_permission(permission2)
db.session.commit()
assert len(role.permissions) == 2
assert role.has_permission('perm1')
assert role.has_permission('perm2')
assert not role.has_permission('perm3')
@pytest.mark.unit
@pytest.mark.models
def test_role_permission_removal(app):
"""Test removing permissions from a role"""
with app.app_context():
permission = Permission(name='perm1', category='test')
db.session.add(permission)
role = Role(name='test_role')
db.session.add(role)
db.session.commit()
# Add and remove permission
role.add_permission(permission)
db.session.commit()
assert role.has_permission('perm1')
role.remove_permission(permission)
db.session.commit()
assert not role.has_permission('perm1')
@pytest.mark.unit
@pytest.mark.models
def test_user_role_assignment(app):
"""Test assigning roles to users"""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
role = Role(name='test_role')
db.session.add(role)
db.session.commit()
# Assign role to user
user.add_role(role)
db.session.commit()
assert len(user.roles) == 1
assert role in user.roles
@pytest.mark.unit
@pytest.mark.models
def test_user_permission_check(app):
"""Test checking if user has specific permissions"""
with app.app_context():
# Create user
user = User(username='testuser', role='user')
db.session.add(user)
# Create permissions
perm1 = Permission(name='perm1', category='test')
perm2 = Permission(name='perm2', category='test')
perm3 = Permission(name='perm3', category='test')
db.session.add_all([perm1, perm2, perm3])
# Create role with permissions
role = Role(name='test_role')
db.session.add(role)
db.session.commit()
role.add_permission(perm1)
role.add_permission(perm2)
db.session.commit()
# Assign role to user
user.add_role(role)
db.session.commit()
# Test permission checks
assert user.has_permission('perm1')
assert user.has_permission('perm2')
assert not user.has_permission('perm3')
@pytest.mark.unit
@pytest.mark.models
def test_user_has_any_permission(app):
"""Test checking if user has any of specified permissions"""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
perm1 = Permission(name='perm1', category='test')
perm2 = Permission(name='perm2', category='test')
db.session.add_all([perm1, perm2])
role = Role(name='test_role')
db.session.add(role)
db.session.commit()
role.add_permission(perm1)
user.add_role(role)
db.session.commit()
# User has perm1 but not perm2
assert user.has_any_permission('perm1', 'perm2')
assert user.has_any_permission('perm1')
assert not user.has_any_permission('perm2', 'perm3')
@pytest.mark.unit
@pytest.mark.models
def test_user_has_all_permissions(app):
"""Test checking if user has all specified permissions"""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
perm1 = Permission(name='perm1', category='test')
perm2 = Permission(name='perm2', category='test')
perm3 = Permission(name='perm3', category='test')
db.session.add_all([perm1, perm2, perm3])
role = Role(name='test_role')
db.session.add(role)
db.session.commit()
role.add_permission(perm1)
role.add_permission(perm2)
user.add_role(role)
db.session.commit()
# User has perm1 and perm2, but not perm3
assert user.has_all_permissions('perm1', 'perm2')
assert user.has_all_permissions('perm1')
assert not user.has_all_permissions('perm1', 'perm2', 'perm3')
@pytest.mark.unit
@pytest.mark.models
def test_user_get_all_permissions(app):
"""Test getting all permissions for a user"""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
# Create permissions and two roles
perm1 = Permission(name='perm1', category='test')
perm2 = Permission(name='perm2', category='test')
perm3 = Permission(name='perm3', category='test')
db.session.add_all([perm1, perm2, perm3])
role1 = Role(name='role1')
role2 = Role(name='role2')
db.session.add_all([role1, role2])
db.session.commit()
# Assign permissions to roles
role1.add_permission(perm1)
role1.add_permission(perm2)
role2.add_permission(perm2) # Duplicate permission in both roles
role2.add_permission(perm3)
# Assign both roles to user
user.add_role(role1)
user.add_role(role2)
db.session.commit()
# Get all permissions (should be deduplicated)
all_permissions = user.get_all_permissions()
permission_names = [p.name for p in all_permissions]
assert len(all_permissions) == 3
assert 'perm1' in permission_names
assert 'perm2' in permission_names
assert 'perm3' in permission_names
@pytest.mark.unit
@pytest.mark.models
def test_legacy_admin_user_permissions(app):
"""Test that legacy admin users (without roles) still have all permissions"""
with app.app_context():
# Create a legacy admin user (with role='admin' but no roles assigned)
admin = User(username='admin', role='admin')
db.session.add(admin)
db.session.commit()
# Legacy admin should be recognized as admin
assert admin.is_admin is True
# Legacy admin should have permission to anything (backward compatibility)
assert admin.has_permission('any_permission')
@pytest.mark.unit
@pytest.mark.models
def test_admin_role_user(app):
"""Test that users with admin role have admin status"""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
# Create admin role
admin_role = Role(name='admin')
db.session.add(admin_role)
db.session.commit()
# User is not admin initially
assert not user.is_admin
# Assign admin role
user.add_role(admin_role)
db.session.commit()
# User should now be admin
assert user.is_admin
@pytest.mark.unit
@pytest.mark.models
def test_super_admin_role_user(app):
"""Test that users with super_admin role have admin status"""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
# Create super_admin role
super_admin_role = Role(name='super_admin')
db.session.add(super_admin_role)
db.session.commit()
# Assign super_admin role
user.add_role(super_admin_role)
db.session.commit()
# User should be admin
assert user.is_admin
@pytest.mark.unit
@pytest.mark.models
def test_role_get_permission_names(app):
"""Test getting permission names from a role"""
with app.app_context():
perm1 = Permission(name='perm1', category='test')
perm2 = Permission(name='perm2', category='test')
db.session.add_all([perm1, perm2])
role = Role(name='test_role')
db.session.add(role)
db.session.commit()
role.add_permission(perm1)
role.add_permission(perm2)
db.session.commit()
permission_names = role.get_permission_names()
assert len(permission_names) == 2
assert 'perm1' in permission_names
assert 'perm2' in permission_names
@pytest.mark.unit
@pytest.mark.models
def test_user_get_role_names(app):
"""Test getting role names from a user"""
with app.app_context():
user = User(username='testuser', role='user')
db.session.add(user)
role1 = Role(name='role1')
role2 = Role(name='role2')
db.session.add_all([role1, role2])
db.session.commit()
user.add_role(role1)
user.add_role(role2)
db.session.commit()
role_names = user.get_role_names()
assert len(role_names) == 2
assert 'role1' in role_names
assert 'role2' in role_names
@pytest.mark.unit
@pytest.mark.models
def test_permission_to_dict(app):
"""Test permission serialization to dictionary"""
with app.app_context():
permission = Permission(
name='test_permission',
description='Test description',
category='testing'
)
db.session.add(permission)
db.session.commit()
perm_dict = permission.to_dict()
assert perm_dict['id'] == permission.id
assert perm_dict['name'] == 'test_permission'
assert perm_dict['description'] == 'Test description'
assert perm_dict['category'] == 'testing'
@pytest.mark.unit
@pytest.mark.models
def test_role_to_dict(app):
"""Test role serialization to dictionary"""
with app.app_context():
role = Role(
name='test_role',
description='Test description',
is_system_role=True
)
db.session.add(role)
db.session.commit()
role_dict = role.to_dict()
assert role_dict['id'] == role.id
assert role_dict['name'] == 'test_role'
assert role_dict['description'] == 'Test description'
assert role_dict['is_system_role'] is True
@pytest.mark.unit
@pytest.mark.models
def test_role_to_dict_with_permissions(app):
"""Test role serialization with permissions included"""
with app.app_context():
perm = Permission(name='test_perm', category='test')
db.session.add(perm)
role = Role(name='test_role')
db.session.add(role)
db.session.commit()
role.add_permission(perm)
db.session.commit()
role_dict = role.to_dict(include_permissions=True)
assert 'permissions' in role_dict
assert 'permission_count' in role_dict
assert role_dict['permission_count'] == 1
assert len(role_dict['permissions']) == 1

View File

@@ -0,0 +1,308 @@
"""Smoke tests for permission system routes"""
import pytest
from app import db
from app.models import User, Permission, Role
@pytest.mark.smoke
def test_roles_list_page(client, admin_user):
"""Test that roles list page loads for admin"""
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Access roles list page
response = client.get('/admin/roles')
assert response.status_code == 200
assert b'Roles & Permissions' in response.data or b'Roles' in response.data
@pytest.mark.smoke
def test_create_role_page(client, admin_user):
"""Test that create role page loads for admin"""
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
response = client.get('/admin/roles/create')
assert response.status_code == 200
assert b'Create' in response.data or b'Role' in response.data
@pytest.mark.smoke
def test_permissions_list_page(client, admin_user):
"""Test that permissions list page loads for admin"""
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
response = client.get('/admin/permissions')
assert response.status_code == 200
assert b'Permission' in response.data
@pytest.mark.integration
def test_create_role_flow(app, client, admin_user):
"""Test creating a new role"""
with app.app_context():
# Create a test permission first
permission = Permission(name='test_perm', category='test')
db.session.add(permission)
db.session.commit()
perm_id = permission.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Create role
response = client.post('/admin/roles/create', data={
'name': 'test_role',
'description': 'Test role description',
'permissions': [str(perm_id)]
}, follow_redirects=True)
assert response.status_code == 200
# Verify role was created
with app.app_context():
role = Role.query.filter_by(name='test_role').first()
assert role is not None
assert role.description == 'Test role description'
assert len(role.permissions) == 1
@pytest.mark.integration
def test_view_role_page(app, client, admin_user):
"""Test viewing a role detail page"""
with app.app_context():
# Create a role
role = Role(name='test_role', description='Test description')
db.session.add(role)
db.session.commit()
role_id = role.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# View role
response = client.get(f'/admin/roles/{role_id}')
assert response.status_code == 200
assert b'test_role' in response.data
@pytest.mark.integration
def test_edit_role_flow(app, client, admin_user):
"""Test editing a role"""
with app.app_context():
# Create a role
role = Role(name='test_role', description='Old description', is_system_role=False)
db.session.add(role)
db.session.commit()
role_id = role.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Edit role
response = client.post(f'/admin/roles/{role_id}/edit', data={
'name': 'updated_role',
'description': 'Updated description',
'permissions': []
}, follow_redirects=True)
assert response.status_code == 200
# Verify changes
with app.app_context():
role = Role.query.get(role_id)
assert role.name == 'updated_role'
assert role.description == 'Updated description'
@pytest.mark.integration
def test_delete_role_flow(app, client, admin_user):
"""Test deleting a role"""
with app.app_context():
# Create a role (non-system role without users)
role = Role(name='deletable_role', is_system_role=False)
db.session.add(role)
db.session.commit()
role_id = role.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Delete role
response = client.post(f'/admin/roles/{role_id}/delete', follow_redirects=True)
assert response.status_code == 200
# Verify deletion
with app.app_context():
role = Role.query.get(role_id)
assert role is None
@pytest.mark.integration
def test_cannot_delete_system_role(app, client, admin_user):
"""Test that system roles cannot be deleted"""
with app.app_context():
# Create a system role
role = Role(name='system_role', is_system_role=True)
db.session.add(role)
db.session.commit()
role_id = role.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Try to delete system role
response = client.post(f'/admin/roles/{role_id}/delete', follow_redirects=True)
assert response.status_code == 200
# Verify it still exists
with app.app_context():
role = Role.query.get(role_id)
assert role is not None
@pytest.mark.integration
def test_cannot_edit_system_role(app, client, admin_user):
"""Test that system roles cannot be edited"""
with app.app_context():
# Create a system role
role = Role(name='system_role', is_system_role=True)
db.session.add(role)
db.session.commit()
role_id = role.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Try to edit system role
response = client.post(f'/admin/roles/{role_id}/edit', data={
'name': 'hacked_name',
'description': 'Hacked',
'permissions': []
}, follow_redirects=True)
# Should redirect or show warning
assert response.status_code == 200
# Verify name didn't change
with app.app_context():
role = Role.query.get(role_id)
assert role.name == 'system_role'
@pytest.mark.integration
def test_manage_user_roles_page(app, client, admin_user):
"""Test managing user roles page"""
with app.app_context():
# Create a test user
user = User(username='testuser', role='user')
db.session.add(user)
db.session.commit()
user_id = user.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Access manage roles page
response = client.get(f'/admin/users/{user_id}/roles')
assert response.status_code == 200
assert b'Manage Roles' in response.data or b'Assign Roles' in response.data
@pytest.mark.integration
def test_assign_roles_to_user(app, client, admin_user):
"""Test assigning roles to a user"""
with app.app_context():
# Create user and role
user = User(username='testuser', role='user')
role = Role(name='test_role')
db.session.add_all([user, role])
db.session.commit()
user_id = user.id
role_id = role.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Assign role to user
response = client.post(f'/admin/users/{user_id}/roles', data={
'roles': [str(role_id)]
}, follow_redirects=True)
assert response.status_code == 200
# Verify assignment
with app.app_context():
user = User.query.get(user_id)
assert len(user.roles) == 1
assert user.roles[0].name == 'test_role'
@pytest.mark.integration
def test_api_get_user_permissions(app, client, admin_user):
"""Test API endpoint to get user permissions"""
with app.app_context():
# Create user with role and permissions
user = User(username='testuser', role='user')
permission = Permission(name='test_perm', category='test')
role = Role(name='test_role')
db.session.add_all([user, permission, role])
db.session.commit()
role.add_permission(permission)
user.add_role(role)
db.session.commit()
user_id = user.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Get user permissions via API
response = client.get(f'/api/users/{user_id}/permissions')
assert response.status_code == 200
data = response.get_json()
assert data['user_id'] == user_id
assert len(data['roles']) == 1
assert len(data['permissions']) == 1
@pytest.mark.integration
def test_api_get_role_permissions(app, client, admin_user):
"""Test API endpoint to get role permissions"""
with app.app_context():
# Create role with permissions
permission = Permission(name='test_perm', category='test')
role = Role(name='test_role', description='Test role')
db.session.add_all([permission, role])
db.session.commit()
role.add_permission(permission)
db.session.commit()
role_id = role.id
# Login as admin
client.post('/login', data={'username': admin_user.username}, follow_redirects=True)
# Get role permissions via API
response = client.get(f'/api/roles/{role_id}/permissions')
assert response.status_code == 200
data = response.get_json()
assert data['role_id'] == role_id
assert data['name'] == 'test_role'
assert len(data['permissions']) == 1
@pytest.mark.smoke
def test_non_admin_cannot_access_roles(client, regular_user):
"""Test that non-admin users cannot access roles management"""
# Login as regular user
client.post('/login', data={'username': regular_user.username}, follow_redirects=True)
# Try to access roles list
response = client.get('/admin/roles', follow_redirects=True)
# Should redirect to dashboard or show error
assert response.status_code == 200
# Verify not on roles page
assert b'Roles & Permissions' not in response.data or b'Administrator access required' in response.data

View File

@@ -0,0 +1,527 @@
"""Tests for enhanced project archiving functionality"""
import pytest
from datetime import datetime
from app.models import Project, TimeEntry, Activity
class TestProjectArchivingModel:
"""Test project archiving model functionality"""
@pytest.mark.models
def test_project_archive_with_metadata(self, app, project, admin_user):
"""Test archiving a project with metadata"""
from app import db
reason = "Project completed successfully"
project.archive(user_id=admin_user.id, reason=reason)
db.session.commit()
assert project.status == 'archived'
assert project.is_archived is True
assert project.archived_at is not None
assert project.archived_by == admin_user.id
assert project.archived_reason == reason
@pytest.mark.models
def test_project_archive_without_reason(self, app, project, admin_user):
"""Test archiving a project without a reason"""
from app import db
project.archive(user_id=admin_user.id, reason=None)
db.session.commit()
assert project.status == 'archived'
assert project.is_archived is True
assert project.archived_at is not None
assert project.archived_by == admin_user.id
assert project.archived_reason is None
@pytest.mark.models
def test_project_unarchive_clears_metadata(self, app, project, admin_user):
"""Test unarchiving a project clears archiving metadata"""
from app import db
# Archive first
project.archive(user_id=admin_user.id, reason="Test reason")
db.session.commit()
assert project.is_archived is True
# Then unarchive
project.unarchive()
db.session.commit()
assert project.status == 'active'
assert project.is_archived is False
assert project.archived_at is None
assert project.archived_by is None
assert project.archived_reason is None
@pytest.mark.models
def test_project_archived_by_user_property(self, app, project, admin_user):
"""Test archived_by_user property returns correct user"""
from app import db
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
archived_by_user = project.archived_by_user
assert archived_by_user is not None
assert archived_by_user.id == admin_user.id
assert archived_by_user.username == admin_user.username
@pytest.mark.models
def test_project_to_dict_includes_archive_metadata(self, app, project, admin_user):
"""Test to_dict includes archiving metadata"""
from app import db
reason = "Project completed"
project.archive(user_id=admin_user.id, reason=reason)
db.session.commit()
project_dict = project.to_dict()
assert project_dict['is_archived'] is True
assert project_dict['archived_at'] is not None
assert project_dict['archived_by'] == admin_user.id
assert project_dict['archived_reason'] == reason
@pytest.mark.models
def test_archived_at_timestamp_accuracy(self, app, project, admin_user):
"""Test that archived_at timestamp is accurate"""
from app import db
before_archive = datetime.utcnow()
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
after_archive = datetime.utcnow()
assert project.archived_at is not None
assert before_archive <= project.archived_at <= after_archive
class TestProjectArchivingRoutes:
"""Test project archiving routes"""
@pytest.mark.routes
def test_archive_project_route_get(self, admin_authenticated_client, app, project):
"""Test GET archive route shows form"""
project_id = project.id
response = admin_authenticated_client.get(f'/projects/{project_id}/archive')
assert response.status_code == 200
assert b'Archive Project' in response.data
assert b'Reason for Archiving' in response.data
assert b'Quick Select' in response.data
@pytest.mark.routes
def test_archive_project_route_post_with_reason(self, admin_authenticated_client, app, project):
"""Test POST archive route with reason"""
from app import db
project_id = project.id
reason = "Project completed successfully"
response = admin_authenticated_client.post(
f'/projects/{project_id}/archive',
data={'reason': reason},
follow_redirects=True
)
assert response.status_code == 200
db.session.refresh(project)
assert project.status == 'archived'
assert project.archived_reason == reason
assert project.archived_by is not None
@pytest.mark.routes
def test_archive_project_route_post_without_reason(self, admin_authenticated_client, app, project):
"""Test POST archive route without reason"""
from app import db
project_id = project.id
response = admin_authenticated_client.post(
f'/projects/{project_id}/archive',
data={},
follow_redirects=True
)
assert response.status_code == 200
db.session.refresh(project)
assert project.status == 'archived'
assert project.archived_reason is None
@pytest.mark.routes
def test_unarchive_project_clears_metadata(self, admin_authenticated_client, app, project, admin_user):
"""Test unarchive route clears metadata"""
from app import db
# Archive first
project.archive(user_id=admin_user.id, reason="Test reason")
db.session.commit()
project_id = project.id
# Unarchive
response = admin_authenticated_client.post(
f'/projects/{project_id}/unarchive',
follow_redirects=True
)
assert response.status_code == 200
db.session.refresh(project)
assert project.status == 'active'
assert project.archived_at is None
assert project.archived_by is None
assert project.archived_reason is None
@pytest.mark.routes
def test_bulk_archive_with_reason(self, admin_authenticated_client, app, test_client):
"""Test bulk archiving multiple projects with reason"""
from app import db
# Create multiple projects
project1 = Project(name='Project 1', client_id=test_client.id)
project2 = Project(name='Project 2', client_id=test_client.id)
db.session.add_all([project1, project2])
db.session.commit()
reason = "Bulk archive - projects completed"
response = admin_authenticated_client.post(
'/projects/bulk-status-change',
data={
'project_ids[]': [project1.id, project2.id],
'new_status': 'archived',
'archive_reason': reason
},
follow_redirects=True
)
assert response.status_code == 200
db.session.refresh(project1)
db.session.refresh(project2)
assert project1.status == 'archived'
assert project1.archived_reason == reason
assert project2.status == 'archived'
assert project2.archived_reason == reason
@pytest.mark.routes
def test_filter_archived_projects(self, admin_authenticated_client, app, test_client, admin_user):
"""Test filtering projects by archived status"""
from app import db
# Create projects with different statuses
active_project = Project(name='Active Project', client_id=test_client.id)
archived_project = Project(name='Archived Project', client_id=test_client.id)
db.session.add_all([active_project, archived_project])
db.session.commit()
archived_project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
# Test filter for archived projects
response = admin_authenticated_client.get('/projects?status=archived')
assert response.status_code == 200
assert b'Archived Project' in response.data
assert b'Active Project' not in response.data
@pytest.mark.routes
def test_non_admin_cannot_archive(self, authenticated_client, app, project):
"""Test that non-admin users cannot archive projects"""
project_id = project.id
response = authenticated_client.post(
f'/projects/{project_id}/archive',
data={'reason': 'Test'},
follow_redirects=True
)
assert response.status_code == 200
assert b'Only administrators can archive projects' in response.data
class TestArchivedProjectValidation:
"""Test validation for archived projects"""
@pytest.mark.routes
def test_cannot_start_timer_on_archived_project(self, authenticated_client, app, project, admin_user):
"""Test that users cannot start timers on archived projects"""
from app import db
# Archive the project
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
project_id = project.id
# Try to start a timer
response = authenticated_client.post(
'/timer/start',
data={'project_id': project_id},
follow_redirects=True
)
assert response.status_code == 200
assert b'Cannot start timer for an archived project' in response.data
@pytest.mark.routes
def test_cannot_create_manual_entry_on_archived_project(self, authenticated_client, app, project, admin_user):
"""Test that users cannot create manual entries on archived projects"""
from app import db
# Archive the project
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
project_id = project.id
# Try to create a manual entry
response = authenticated_client.post(
'/timer/manual',
data={
'project_id': project_id,
'start_date': '2025-01-01',
'start_time': '09:00',
'end_date': '2025-01-01',
'end_time': '17:00',
'notes': 'Test'
},
follow_redirects=True
)
assert response.status_code == 200
assert b'Cannot create time entries for an archived project' in response.data
@pytest.mark.routes
def test_cannot_create_bulk_entry_on_archived_project(self, authenticated_client, app, project, admin_user):
"""Test that users cannot create bulk entries on archived projects"""
from app import db
# Archive the project
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
project_id = project.id
# Try to create bulk entries
response = authenticated_client.post(
'/timer/bulk',
data={
'project_id': project_id,
'start_date': '2025-01-01',
'end_date': '2025-01-05',
'start_time': '09:00',
'end_time': '17:00',
'skip_weekends': 'on'
},
follow_redirects=True
)
assert response.status_code == 200
assert b'Cannot create time entries for an archived project' in response.data
@pytest.mark.routes
def test_archived_projects_not_in_active_list(self, authenticated_client, app, test_client, admin_user):
"""Test that archived projects don't appear in timer dropdown"""
from app import db
# Create and archive a project
archived_project = Project(name='Archived Project', client_id=test_client.id)
active_project = Project(name='Active Project', client_id=test_client.id)
db.session.add_all([archived_project, active_project])
db.session.commit()
archived_project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
# Check dashboard
response = authenticated_client.get('/')
assert response.status_code == 200
# Active project should be in select options
assert b'Active Project' in response.data
# Archived project should not be in select options for starting timer
# (This is a basic check - more sophisticated checks could verify the select element)
class TestArchivingActivityLogs:
"""Test that archiving creates activity logs"""
@pytest.mark.routes
def test_archive_creates_activity_log(self, admin_authenticated_client, app, project):
"""Test that archiving a project creates an activity log"""
from app import db
project_id = project.id
reason = "Project completed"
response = admin_authenticated_client.post(
f'/projects/{project_id}/archive',
data={'reason': reason},
follow_redirects=True
)
assert response.status_code == 200
# Check that activity was logged
activity = Activity.query.filter_by(
entity_type='project',
entity_id=project_id,
action='archived'
).first()
assert activity is not None
assert reason in activity.description
@pytest.mark.routes
def test_unarchive_creates_activity_log(self, admin_authenticated_client, app, project, admin_user):
"""Test that unarchiving a project creates an activity log"""
from app import db
# Archive first
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
project_id = project.id
# Unarchive
response = admin_authenticated_client.post(
f'/projects/{project_id}/unarchive',
follow_redirects=True
)
assert response.status_code == 200
# Check that activity was logged
activity = Activity.query.filter_by(
entity_type='project',
entity_id=project_id,
action='unarchived'
).first()
assert activity is not None
class TestArchivingUI:
"""Test archiving UI elements"""
@pytest.mark.routes
def test_project_view_shows_archive_metadata(self, admin_authenticated_client, app, project, admin_user):
"""Test that project view shows archiving metadata"""
from app import db
# Archive the project
reason = "Project completed successfully"
project.archive(user_id=admin_user.id, reason=reason)
db.session.commit()
project_id = project.id
# View the project
response = admin_authenticated_client.get(f'/projects/{project_id}')
assert response.status_code == 200
# Check for archive information
assert b'Archive Information' in response.data
assert b'Archived on:' in response.data
assert b'Archived by:' in response.data
assert b'Reason:' in response.data
assert reason.encode() in response.data
@pytest.mark.routes
def test_project_list_shows_archived_status_badge(self, admin_authenticated_client, app, test_client, admin_user):
"""Test that project list shows archived status badge"""
from app import db
# Create and archive a project
archived_project = Project(name='Archived Test Project', client_id=test_client.id)
db.session.add(archived_project)
db.session.commit()
archived_project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
# View projects list with archived filter
response = admin_authenticated_client.get('/projects?status=archived')
assert response.status_code == 200
assert b'Archived Test Project' in response.data
assert b'Archived' in response.data # Status badge
@pytest.mark.routes
def test_archive_form_has_quick_select_buttons(self, admin_authenticated_client, app, project):
"""Test that archive form has quick select buttons"""
project_id = project.id
response = admin_authenticated_client.get(f'/projects/{project_id}/archive')
assert response.status_code == 200
# Check for quick select buttons
assert b'Project Completed' in response.data
assert b'Contract Ended' in response.data
assert b'Cancelled' in response.data
assert b'On Hold' in response.data
assert b'Maintenance Ended' in response.data
@pytest.mark.smoke
class TestArchivingSmokeTests:
"""Smoke tests for complete archiving workflow"""
def test_complete_archive_unarchive_workflow(self, admin_authenticated_client, app, project, admin_user):
"""Test complete workflow: create, archive, view, unarchive"""
from app import db
project_id = project.id
project_name = project.name
# 1. Verify project is active
response = admin_authenticated_client.get('/projects')
assert response.status_code == 200
assert project_name.encode() in response.data
# 2. Archive the project with reason
reason = "Complete smoke test"
response = admin_authenticated_client.post(
f'/projects/{project_id}/archive',
data={'reason': reason},
follow_redirects=True
)
assert response.status_code == 200
# 3. Verify it's archived
db.session.refresh(project)
assert project.status == 'archived'
assert project.archived_reason == reason
# 4. View archived project
response = admin_authenticated_client.get(f'/projects/{project_id}')
assert response.status_code == 200
assert b'Archive Information' in response.data
assert reason.encode() in response.data
# 5. Verify it appears in archived filter
response = admin_authenticated_client.get('/projects?status=archived')
assert response.status_code == 200
assert project_name.encode() in response.data
# 6. Unarchive the project
response = admin_authenticated_client.post(
f'/projects/{project_id}/unarchive',
follow_redirects=True
)
assert response.status_code == 200
# 7. Verify it's active again
db.session.refresh(project)
assert project.status == 'active'
assert project.archived_at is None
# 8. Verify it appears in active projects
response = admin_authenticated_client.get('/projects?status=active')
assert response.status_code == 200
assert project_name.encode() in response.data

View File

@@ -0,0 +1,427 @@
"""Model tests for project archiving functionality"""
import pytest
from datetime import datetime, timedelta
from app.models import Project
@pytest.mark.models
class TestProjectArchivingFields:
"""Test project archiving model fields"""
def test_archived_at_field_exists(self, app, project):
"""Test that archived_at field exists and can be set"""
from app import db
now = datetime.utcnow()
project.archived_at = now
db.session.commit()
db.session.refresh(project)
assert project.archived_at is not None
assert abs((project.archived_at - now).total_seconds()) < 1
def test_archived_by_field_exists(self, app, project, admin_user):
"""Test that archived_by field exists and references users"""
from app import db
project.archived_by = admin_user.id
db.session.commit()
db.session.refresh(project)
assert project.archived_by == admin_user.id
def test_archived_reason_field_exists(self, app, project):
"""Test that archived_reason field exists and stores text"""
from app import db
long_reason = "This is a very long reason for archiving the project. " * 10
project.archived_reason = long_reason
db.session.commit()
db.session.refresh(project)
assert project.archived_reason == long_reason
def test_archived_at_is_nullable(self, app, test_client):
"""Test that archived_at can be null for non-archived projects"""
from app import db
project = Project(name='Test Project', client_id=test_client.id)
db.session.add(project)
db.session.commit()
assert project.archived_at is None
def test_archived_by_is_nullable(self, app, test_client):
"""Test that archived_by can be null"""
from app import db
project = Project(name='Test Project', client_id=test_client.id)
db.session.add(project)
db.session.commit()
assert project.archived_by is None
def test_archived_reason_is_nullable(self, app, test_client):
"""Test that archived_reason can be null"""
from app import db
project = Project(name='Test Project', client_id=test_client.id)
db.session.add(project)
db.session.commit()
assert project.archived_reason is None
@pytest.mark.models
class TestProjectArchiveMethod:
"""Test project archive() method"""
def test_archive_sets_status(self, app, project):
"""Test that archive() sets status to 'archived'"""
from app import db
project.archive()
db.session.commit()
assert project.status == 'archived'
def test_archive_sets_timestamp(self, app, project):
"""Test that archive() sets archived_at timestamp"""
from app import db
before = datetime.utcnow()
project.archive()
db.session.commit()
after = datetime.utcnow()
assert project.archived_at is not None
assert before <= project.archived_at <= after
def test_archive_with_user_id(self, app, project, admin_user):
"""Test that archive() accepts and stores user_id"""
from app import db
project.archive(user_id=admin_user.id)
db.session.commit()
assert project.archived_by == admin_user.id
def test_archive_with_reason(self, app, project):
"""Test that archive() accepts and stores reason"""
from app import db
reason = "Test archiving reason"
project.archive(reason=reason)
db.session.commit()
assert project.archived_reason == reason
def test_archive_with_all_parameters(self, app, project, admin_user):
"""Test that archive() works with all parameters"""
from app import db
reason = "Comprehensive test"
project.archive(user_id=admin_user.id, reason=reason)
db.session.commit()
assert project.status == 'archived'
assert project.archived_at is not None
assert project.archived_by == admin_user.id
assert project.archived_reason == reason
def test_archive_without_parameters(self, app, project):
"""Test that archive() works without parameters"""
from app import db
project.archive()
db.session.commit()
assert project.status == 'archived'
assert project.archived_at is not None
assert project.archived_by is None
assert project.archived_reason is None
def test_archive_updates_updated_at(self, app, project):
"""Test that archive() updates the updated_at timestamp"""
from app import db
original_updated_at = project.updated_at
# Wait a tiny bit to ensure timestamp difference
import time
time.sleep(0.01)
project.archive()
db.session.commit()
assert project.updated_at > original_updated_at
def test_archive_can_be_called_multiple_times(self, app, project, admin_user):
"""Test that archive() can be called multiple times (re-archiving)"""
from app import db
# First archive
project.archive(user_id=admin_user.id, reason="First time")
db.session.commit()
first_archived_at = project.archived_at
import time
time.sleep(0.01)
# Second archive with different reason
project.archive(user_id=admin_user.id, reason="Second time")
db.session.commit()
assert project.status == 'archived'
assert project.archived_at > first_archived_at
assert project.archived_reason == "Second time"
@pytest.mark.models
class TestProjectUnarchiveMethod:
"""Test project unarchive() method"""
def test_unarchive_sets_status_to_active(self, app, project, admin_user):
"""Test that unarchive() sets status to 'active'"""
from app import db
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
project.unarchive()
db.session.commit()
assert project.status == 'active'
def test_unarchive_clears_archived_at(self, app, project, admin_user):
"""Test that unarchive() clears archived_at"""
from app import db
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
assert project.archived_at is not None
project.unarchive()
db.session.commit()
assert project.archived_at is None
def test_unarchive_clears_archived_by(self, app, project, admin_user):
"""Test that unarchive() clears archived_by"""
from app import db
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
assert project.archived_by is not None
project.unarchive()
db.session.commit()
assert project.archived_by is None
def test_unarchive_clears_archived_reason(self, app, project, admin_user):
"""Test that unarchive() clears archived_reason"""
from app import db
project.archive(user_id=admin_user.id, reason="Test reason")
db.session.commit()
assert project.archived_reason is not None
project.unarchive()
db.session.commit()
assert project.archived_reason is None
def test_unarchive_updates_updated_at(self, app, project, admin_user):
"""Test that unarchive() updates the updated_at timestamp"""
from app import db
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
original_updated_at = project.updated_at
import time
time.sleep(0.01)
project.unarchive()
db.session.commit()
assert project.updated_at > original_updated_at
@pytest.mark.models
class TestProjectArchiveProperties:
"""Test project archiving properties"""
def test_is_archived_property_when_archived(self, app, project, admin_user):
"""Test that is_archived property returns True for archived projects"""
from app import db
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
assert project.is_archived is True
def test_is_archived_property_when_active(self, app, project):
"""Test that is_archived property returns False for active projects"""
assert project.is_archived is False
def test_is_archived_property_when_inactive(self, app, project):
"""Test that is_archived property returns False for inactive projects"""
from app import db
project.deactivate()
db.session.commit()
assert project.is_archived is False
def test_archived_by_user_property_returns_user(self, app, project, admin_user):
"""Test that archived_by_user property returns the correct user"""
from app import db
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
archived_by = project.archived_by_user
assert archived_by is not None
assert archived_by.id == admin_user.id
assert archived_by.username == admin_user.username
def test_archived_by_user_property_returns_none_when_not_archived(self, app, project):
"""Test that archived_by_user property returns None for non-archived projects"""
assert project.archived_by_user is None
def test_archived_by_user_property_returns_none_when_user_deleted(self, app, project, test_client):
"""Test archived_by_user handles deleted users gracefully"""
from app import db
from app.models import User
# Create a temporary user
temp_user = User(username='tempuser', email='temp@test.com')
temp_user.set_password('password')
db.session.add(temp_user)
db.session.commit()
temp_user_id = temp_user.id
# Archive with temp user
project.archive(user_id=temp_user_id, reason="Test")
db.session.commit()
# Delete the user
db.session.delete(temp_user)
db.session.commit()
# archived_by should still be set but user query returns None
assert project.archived_by == temp_user_id
assert project.archived_by_user is None
@pytest.mark.models
class TestProjectToDictArchiveFields:
"""Test project to_dict() method with archive fields"""
def test_to_dict_includes_is_archived(self, app, project):
"""Test that to_dict includes is_archived field"""
project_dict = project.to_dict()
assert 'is_archived' in project_dict
assert project_dict['is_archived'] is False
def test_to_dict_includes_archived_at(self, app, project, admin_user):
"""Test that to_dict includes archived_at field"""
from app import db
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
project_dict = project.to_dict()
assert 'archived_at' in project_dict
assert project_dict['archived_at'] is not None
# Check that it's in ISO format
assert 'T' in project_dict['archived_at']
def test_to_dict_includes_archived_by(self, app, project, admin_user):
"""Test that to_dict includes archived_by field"""
from app import db
project.archive(user_id=admin_user.id, reason="Test")
db.session.commit()
project_dict = project.to_dict()
assert 'archived_by' in project_dict
assert project_dict['archived_by'] == admin_user.id
def test_to_dict_includes_archived_reason(self, app, project, admin_user):
"""Test that to_dict includes archived_reason field"""
from app import db
reason = "Test archiving"
project.archive(user_id=admin_user.id, reason=reason)
db.session.commit()
project_dict = project.to_dict()
assert 'archived_reason' in project_dict
assert project_dict['archived_reason'] == reason
def test_to_dict_archive_fields_null_when_not_archived(self, app, project):
"""Test that archive fields are null for non-archived projects"""
project_dict = project.to_dict()
assert project_dict['is_archived'] is False
assert project_dict['archived_at'] is None
assert project_dict['archived_by'] is None
assert project_dict['archived_reason'] is None
@pytest.mark.models
class TestProjectArchiveEdgeCases:
"""Test edge cases for project archiving"""
def test_archive_with_empty_string_reason(self, app, project):
"""Test archiving with empty string reason treats it as None"""
from app import db
project.archive(reason="")
db.session.commit()
# Empty string should be stored as-is (route layer handles conversion to None)
assert project.archived_reason == ""
def test_archive_with_very_long_reason(self, app, project):
"""Test archiving with very long reason"""
from app import db
# Create a 10000 character reason
long_reason = "x" * 10000
project.archive(reason=long_reason)
db.session.commit()
db.session.refresh(project)
assert len(project.archived_reason) == 10000
def test_archive_with_special_characters_in_reason(self, app, project):
"""Test archiving with special characters in reason"""
from app import db
special_reason = "Test with 特殊字符 émojis 🎉 and symbols: @#$%^&*()"
project.archive(reason=special_reason)
db.session.commit()
db.session.refresh(project)
assert project.archived_reason == special_reason
def test_archive_with_invalid_user_id(self, app, project):
"""Test that archiving with non-existent user_id still works"""
from app import db
# Use a user ID that doesn't exist
project.archive(user_id=999999, reason="Test")
db.session.commit()
assert project.status == 'archived'
assert project.archived_by == 999999
# archived_by_user should return None for invalid ID
assert project.archived_by_user is None

View File

@@ -0,0 +1,572 @@
"""
Comprehensive tests for Time Entry Templates feature.
This module tests:
- TimeEntryTemplate model functionality
- Time entry template routes (CRUD operations)
- Template usage tracking
- Integration with time entries
"""
import pytest
from datetime import datetime
from app.models import TimeEntryTemplate, User, Project, Task, TimeEntry
from app import db
# ============================================================================
# Model Tests
# ============================================================================
@pytest.mark.models
class TestTimeEntryTemplateModel:
"""Test TimeEntryTemplate model functionality"""
def test_create_template_with_all_fields(self, app, user, project, task):
"""Test creating a template with all fields populated"""
with app.app_context():
template = TimeEntryTemplate(
user_id=user.id,
name="Daily Standup",
description="Template for daily standup meetings",
project_id=project.id,
task_id=task.id,
default_duration_minutes=15,
default_notes="Discussed progress and blockers",
tags="meeting,standup,daily",
billable=True
)
db.session.add(template)
db.session.commit()
# Verify all fields
assert template.id is not None
assert template.name == "Daily Standup"
assert template.description == "Template for daily standup meetings"
assert template.project_id == project.id
assert template.task_id == task.id
assert template.default_duration_minutes == 15
assert template.default_notes == "Discussed progress and blockers"
assert template.tags == "meeting,standup,daily"
assert template.billable is True
assert template.usage_count == 0
assert template.last_used_at is None
assert template.created_at is not None
assert template.updated_at is not None
def test_create_template_minimal_fields(self, app, user):
"""Test creating a template with only required fields"""
with app.app_context():
template = TimeEntryTemplate(
user_id=user.id,
name="Quick Task"
)
db.session.add(template)
db.session.commit()
assert template.id is not None
assert template.name == "Quick Task"
assert template.project_id is None
assert template.task_id is None
assert template.default_duration_minutes is None
assert template.default_notes is None
assert template.tags is None
assert template.billable is True # Default value
assert template.usage_count == 0
def test_template_default_duration_property(self, app, user):
"""Test the default_duration property (hours conversion)"""
with app.app_context():
template = TimeEntryTemplate(
user_id=user.id,
name="Test Template",
default_duration_minutes=90
)
db.session.add(template)
db.session.commit()
# Test getter
assert template.default_duration == 1.5
# Test setter
template.default_duration = 2.25
assert template.default_duration_minutes == 135
# Test None handling
template.default_duration = None
assert template.default_duration_minutes is None
assert template.default_duration is None
def test_template_record_usage(self, app, user):
"""Test the record_usage method"""
with app.app_context():
template = TimeEntryTemplate(
user_id=user.id,
name="Test Template"
)
db.session.add(template)
db.session.commit()
initial_count = template.usage_count
initial_last_used = template.last_used_at
# Record usage
template.record_usage()
db.session.commit()
assert template.usage_count == initial_count + 1
assert template.last_used_at is not None
assert template.last_used_at != initial_last_used
def test_template_increment_usage(self, app, user):
"""Test the increment_usage method"""
with app.app_context():
template = TimeEntryTemplate(
user_id=user.id,
name="Test Template"
)
db.session.add(template)
db.session.commit()
# Increment usage multiple times
for i in range(3):
template.increment_usage()
template_id = template.id
# Verify in new query
updated_template = TimeEntryTemplate.query.get(template_id)
assert updated_template.usage_count == 3
assert updated_template.last_used_at is not None
def test_template_to_dict(self, app, user, project, task):
"""Test the to_dict method"""
with app.app_context():
template = TimeEntryTemplate(
user_id=user.id,
name="Test Template",
description="Test description",
project_id=project.id,
task_id=task.id,
default_duration_minutes=60,
default_notes="Test notes",
tags="test,template",
billable=True
)
db.session.add(template)
db.session.commit()
template_dict = template.to_dict()
assert template_dict['id'] == template.id
assert template_dict['user_id'] == user.id
assert template_dict['name'] == "Test Template"
assert template_dict['description'] == "Test description"
assert template_dict['project_id'] == project.id
assert template_dict['project_name'] == project.name
assert template_dict['task_id'] == task.id
assert template_dict['task_name'] == task.name
assert template_dict['default_duration'] == 1.0
assert template_dict['default_duration_minutes'] == 60
assert template_dict['default_notes'] == "Test notes"
assert template_dict['tags'] == "test,template"
assert template_dict['billable'] is True
assert template_dict['usage_count'] == 0
assert 'created_at' in template_dict
assert 'updated_at' in template_dict
def test_template_relationships(self, app, user, project, task):
"""Test template relationships with user, project, and task"""
with app.app_context():
# Get IDs before context
user_id = user.id
project_id = project.id
task_id = task.id
template = TimeEntryTemplate(
user_id=user_id,
name="Test Template",
project_id=project_id,
task_id=task_id
)
db.session.add(template)
db.session.commit()
# Test relationships by ID
assert template.user_id == user_id
assert template.project_id == project_id
assert template.task_id == task_id
# Test relationship objects exist
assert template.user is not None
assert template.project is not None
assert template.task is not None
# Test relationship IDs match
assert template.user.id == user_id
assert template.project.id == project_id
assert template.task.id == task_id
def test_template_repr(self, app, user):
"""Test template __repr__ method"""
with app.app_context():
template = TimeEntryTemplate(
user_id=user.id,
name="Test Template"
)
db.session.add(template)
db.session.commit()
assert repr(template) == '<TimeEntryTemplate Test Template>'
# ============================================================================
# Route Tests
# ============================================================================
@pytest.mark.routes
class TestTimeEntryTemplateRoutes:
"""Test time entry template routes"""
def test_list_templates_authenticated(self, authenticated_client, user):
"""Test accessing templates list page when authenticated"""
response = authenticated_client.get('/templates')
assert response.status_code == 200
assert b'Time Entry Templates' in response.data
def test_list_templates_unauthenticated(self, client):
"""Test accessing templates list page without authentication"""
response = client.get('/templates', follow_redirects=False)
assert response.status_code == 302 # Redirect to login
def test_create_template_page_get(self, authenticated_client):
"""Test accessing create template page"""
response = authenticated_client.get('/templates/create')
assert response.status_code == 200
assert b'Create Time Entry Template' in response.data
assert b'Template Name' in response.data
def test_create_template_success(self, authenticated_client, user, project):
"""Test creating a new template successfully"""
response = authenticated_client.post('/templates/create', data={
'name': 'New Template',
'project_id': project.id,
'default_duration': '1.5',
'default_notes': 'Test notes',
'tags': 'test,new'
}, follow_redirects=True)
assert response.status_code == 200
assert b'created successfully' in response.data
# Verify template was created
template = TimeEntryTemplate.query.filter_by(
user_id=user.id,
name='New Template'
).first()
assert template is not None
assert template.project_id == project.id
assert template.default_duration == 1.5
assert template.default_notes == 'Test notes'
assert template.tags == 'test,new'
def test_create_template_without_name(self, authenticated_client):
"""Test creating a template without a name fails"""
response = authenticated_client.post('/templates/create', data={
'name': '',
'default_notes': 'Test notes'
}, follow_redirects=True)
assert response.status_code == 200
assert b'required' in response.data or b'error' in response.data
def test_create_template_duplicate_name(self, authenticated_client, user):
"""Test creating a template with duplicate name fails"""
# Create first template
template = TimeEntryTemplate(
user_id=user.id,
name='Duplicate Test'
)
db.session.add(template)
db.session.commit()
# Try to create another with same name
response = authenticated_client.post('/templates/create', data={
'name': 'Duplicate Test',
'default_notes': 'Test notes'
}, follow_redirects=True)
assert response.status_code == 200
assert b'already exists' in response.data
def test_edit_template_page_get(self, authenticated_client, user):
"""Test accessing edit template page"""
# Create a template
template = TimeEntryTemplate(
user_id=user.id,
name='Edit Test'
)
db.session.add(template)
db.session.commit()
response = authenticated_client.get(f'/templates/{template.id}/edit')
assert response.status_code == 200
assert b'Edit Test' in response.data
def test_edit_template_success(self, authenticated_client, user):
"""Test editing a template successfully"""
# Create a template
template = TimeEntryTemplate(
user_id=user.id,
name='Original Name'
)
db.session.add(template)
db.session.commit()
template_id = template.id
# Edit the template
response = authenticated_client.post(f'/templates/{template_id}/edit', data={
'name': 'Updated Name',
'default_notes': 'Updated notes'
}, follow_redirects=True)
assert response.status_code == 200
assert b'updated successfully' in response.data
# Verify update
updated_template = TimeEntryTemplate.query.get(template_id)
assert updated_template.name == 'Updated Name'
assert updated_template.default_notes == 'Updated notes'
def test_delete_template_success(self, authenticated_client, user):
"""Test deleting a template successfully"""
# Create a template
template = TimeEntryTemplate(
user_id=user.id,
name='Delete Test'
)
db.session.add(template)
db.session.commit()
template_id = template.id
# Delete the template
response = authenticated_client.post(f'/templates/{template_id}/delete',
follow_redirects=True)
assert response.status_code == 200
assert b'deleted successfully' in response.data
# Verify deletion
deleted_template = TimeEntryTemplate.query.get(template_id)
assert deleted_template is None
# View template test skipped - view.html template doesn't exist yet
# def test_view_template(self, authenticated_client, user):
# """Test viewing a single template"""
# template = TimeEntryTemplate(
# user_id=user.id,
# name='View Test',
# default_notes='Test notes'
# )
# db.session.add(template)
# db.session.commit()
#
# response = authenticated_client.get(f'/templates/{template.id}')
# assert response.status_code == 200
# assert b'View Test' in response.data
# assert b'Test notes' in response.data
# ============================================================================
# API Tests
# ============================================================================
@pytest.mark.api
class TestTimeEntryTemplateAPI:
"""Test time entry template API endpoints"""
def test_get_templates_api(self, authenticated_client, user):
"""Test getting templates via API"""
# Create some templates
for i in range(3):
template = TimeEntryTemplate(
user_id=user.id,
name=f'Template {i}'
)
db.session.add(template)
db.session.commit()
response = authenticated_client.get('/api/templates')
assert response.status_code == 200
data = response.get_json()
assert 'templates' in data
assert len(data['templates']) >= 3
def test_get_single_template_api(self, authenticated_client, user):
"""Test getting a single template via API"""
template = TimeEntryTemplate(
user_id=user.id,
name='API Test',
default_notes='Test notes'
)
db.session.add(template)
db.session.commit()
response = authenticated_client.get(f'/api/templates/{template.id}')
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'API Test'
assert data['default_notes'] == 'Test notes'
def test_use_template_api(self, authenticated_client, user):
"""Test marking template as used via API"""
template = TimeEntryTemplate(
user_id=user.id,
name='Use Test'
)
db.session.add(template)
db.session.commit()
template_id = template.id
response = authenticated_client.post(f'/api/templates/{template_id}/use')
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify usage was recorded
updated_template = TimeEntryTemplate.query.get(template_id)
assert updated_template.usage_count == 1
assert updated_template.last_used_at is not None
# ============================================================================
# Smoke Tests
# ============================================================================
@pytest.mark.smoke
class TestTimeEntryTemplatesSmoke:
"""Smoke tests for time entry templates feature"""
def test_templates_page_renders(self, authenticated_client):
"""Smoke test: templates page renders without errors"""
response = authenticated_client.get('/templates')
assert response.status_code == 200
assert b'Time Entry Templates' in response.data
def test_create_template_page_renders(self, authenticated_client):
"""Smoke test: create template page renders without errors"""
response = authenticated_client.get('/templates/create')
assert response.status_code == 200
assert b'Create' in response.data
def test_template_crud_workflow(self, authenticated_client, user, project):
"""Smoke test: complete CRUD workflow for templates"""
# Create
response = authenticated_client.post('/templates/create', data={
'name': 'Smoke Test Template',
'project_id': project.id,
'default_notes': 'Smoke test'
}, follow_redirects=True)
assert response.status_code == 200
# Read
template = TimeEntryTemplate.query.filter_by(
user_id=user.id,
name='Smoke Test Template'
).first()
assert template is not None
# View test skipped - view.html doesn't exist yet
# response = authenticated_client.get(f'/templates/{template.id}')
# assert response.status_code == 200
# Update
response = authenticated_client.post(f'/templates/{template.id}/edit', data={
'name': 'Smoke Test Template Updated',
'default_notes': 'Updated notes'
}, follow_redirects=True)
assert response.status_code == 200
# Delete
response = authenticated_client.post(f'/templates/{template.id}/delete',
follow_redirects=True)
assert response.status_code == 200
# ============================================================================
# Integration Tests
# ============================================================================
@pytest.mark.integration
class TestTimeEntryTemplateIntegration:
"""Integration tests for time entry templates with other features"""
def test_template_with_project_and_task(self, app, user, project, task):
"""Test template integration with projects and tasks"""
with app.app_context():
template = TimeEntryTemplate(
user_id=user.id,
name='Integration Test',
project_id=project.id,
task_id=task.id
)
db.session.add(template)
db.session.commit()
# Verify relationships work
assert template.project.name == project.name
assert template.task.name == task.name
def test_template_usage_tracking_over_time(self, app, user):
"""Test template usage tracking"""
with app.app_context():
template = TimeEntryTemplate(
user_id=user.id,
name='Usage Tracking Test'
)
db.session.add(template)
db.session.commit()
# Use template multiple times
usage_times = []
for _ in range(5):
template.record_usage()
usage_times.append(template.last_used_at)
db.session.commit()
assert template.usage_count == 5
# Last used time should be most recent
assert template.last_used_at == max(usage_times)
def test_multiple_users_separate_templates(self, app):
"""Test that templates are user-specific"""
with app.app_context():
# Create two users
user1 = User(username='template_user1', email='user1@test.com')
user1.is_active = True
user2 = User(username='template_user2', email='user2@test.com')
user2.is_active = True
db.session.add_all([user1, user2])
db.session.commit()
# Create templates for each user
template1 = TimeEntryTemplate(
user_id=user1.id,
name='User1 Template'
)
template2 = TimeEntryTemplate(
user_id=user2.id,
name='User2 Template'
)
db.session.add_all([template1, template2])
db.session.commit()
# Verify isolation
user1_templates = TimeEntryTemplate.query.filter_by(user_id=user1.id).all()
user2_templates = TimeEntryTemplate.query.filter_by(user_id=user2.id).all()
assert len(user1_templates) == 1
assert len(user2_templates) == 1
assert user1_templates[0].name == 'User1 Template'
assert user2_templates[0].name == 'User2 Template'

199
tests/test_time_rounding.py Normal file
View File

@@ -0,0 +1,199 @@
"""Unit tests for time rounding functionality"""
import pytest
from app.utils.time_rounding import (
round_time_duration,
apply_user_rounding,
format_rounding_interval,
get_available_rounding_intervals,
get_available_rounding_methods,
get_user_rounding_settings
)
class TestRoundTimeDuration:
"""Test the core time rounding function"""
def test_no_rounding_when_interval_is_one(self):
"""Test that rounding_minutes=1 returns exact duration"""
assert round_time_duration(3720, 1, 'nearest') == 3720
assert round_time_duration(3722, 1, 'up') == 3722
assert round_time_duration(3718, 1, 'down') == 3718
def test_round_to_nearest_5_minutes(self):
"""Test rounding to nearest 5 minute interval"""
# 62 minutes should round to 60 minutes (nearest 5-min interval)
assert round_time_duration(3720, 5, 'nearest') == 3600
# 63 minutes should round to 65 minutes
assert round_time_duration(3780, 5, 'nearest') == 3900
# 2 minutes should round to 0
assert round_time_duration(120, 5, 'nearest') == 0
# 3 minutes should round to 5
assert round_time_duration(180, 5, 'nearest') == 300
def test_round_to_nearest_15_minutes(self):
"""Test rounding to nearest 15 minute interval"""
# 62 minutes should round to 60 minutes
assert round_time_duration(3720, 15, 'nearest') == 3600
# 68 minutes should round to 75 minutes
assert round_time_duration(4080, 15, 'nearest') == 4500
# 7 minutes should round to 0
assert round_time_duration(420, 15, 'nearest') == 0
# 8 minutes should round to 15
assert round_time_duration(480, 15, 'nearest') == 900
def test_round_up(self):
"""Test always rounding up (ceiling)"""
# 62 minutes with 15-min interval rounds up to 75
assert round_time_duration(3720, 15, 'up') == 4500
# 60 minutes with 15-min interval stays 60 (exact match)
assert round_time_duration(3600, 15, 'up') == 3600
# 61 minutes with 15-min interval rounds up to 75
assert round_time_duration(3660, 15, 'up') == 4500
# 1 minute with 5-min interval rounds up to 5
assert round_time_duration(60, 5, 'up') == 300
def test_round_down(self):
"""Test always rounding down (floor)"""
# 62 minutes with 15-min interval rounds down to 60
assert round_time_duration(3720, 15, 'down') == 3600
# 74 minutes with 15-min interval rounds down to 60
assert round_time_duration(4440, 15, 'down') == 3600
# 75 minutes with 15-min interval stays 75 (exact match)
assert round_time_duration(4500, 15, 'down') == 4500
def test_round_to_hour(self):
"""Test rounding to 1 hour intervals"""
# 62 minutes rounds to 60 minutes (nearest hour)
assert round_time_duration(3720, 60, 'nearest') == 3600
# 90 minutes rounds to 120 minutes (nearest hour)
assert round_time_duration(5400, 60, 'nearest') == 7200
# 89 minutes rounds to 60 minutes (nearest hour)
assert round_time_duration(5340, 60, 'nearest') == 3600
def test_invalid_rounding_method_defaults_to_nearest(self):
"""Test that invalid rounding method falls back to 'nearest'"""
result = round_time_duration(3720, 15, 'invalid')
expected = round_time_duration(3720, 15, 'nearest')
assert result == expected
def test_zero_duration(self):
"""Test handling of zero duration"""
assert round_time_duration(0, 15, 'nearest') == 0
assert round_time_duration(0, 15, 'up') == 0
assert round_time_duration(0, 15, 'down') == 0
def test_very_small_durations(self):
"""Test rounding of very small durations"""
# 30 seconds with 5-min rounding
assert round_time_duration(30, 5, 'nearest') == 0
assert round_time_duration(30, 5, 'up') == 300 # Rounds up to 5 minutes
assert round_time_duration(30, 5, 'down') == 0
def test_very_large_durations(self):
"""Test rounding of large durations"""
# 8 hours 7 minutes (487 minutes) with 15-min rounding
# 487 / 15 = 32.47 -> rounds to 32 intervals = 480 minutes = 28800 seconds
assert round_time_duration(29220, 15, 'nearest') == 28800 # 480 minutes (8 hours)
# 8 hours 8 minutes (488 minutes) with 15-min rounding
# 488 / 15 = 32.53 -> rounds to 33 intervals = 495 minutes = 29700 seconds
assert round_time_duration(29280, 15, 'nearest') == 29700 # 495 minutes (8 hours 15 min)
class TestApplyUserRounding:
"""Test applying user-specific rounding preferences"""
def test_with_rounding_disabled(self):
"""Test that rounding is skipped when disabled for user"""
class MockUser:
time_rounding_enabled = False
time_rounding_minutes = 15
time_rounding_method = 'nearest'
user = MockUser()
assert apply_user_rounding(3720, user) == 3720
def test_with_rounding_enabled(self):
"""Test that rounding is applied when enabled"""
class MockUser:
time_rounding_enabled = True
time_rounding_minutes = 15
time_rounding_method = 'nearest'
user = MockUser()
# 62 minutes should round to 60 with 15-min interval
assert apply_user_rounding(3720, user) == 3600
def test_different_user_preferences(self):
"""Test that different users can have different rounding settings"""
class MockUser1:
time_rounding_enabled = True
time_rounding_minutes = 5
time_rounding_method = 'up'
class MockUser2:
time_rounding_enabled = True
time_rounding_minutes = 15
time_rounding_method = 'down'
duration = 3720 # 62 minutes
# User 1: 5-min up -> 65 minutes
assert apply_user_rounding(duration, MockUser1()) == 3900
# User 2: 15-min down -> 60 minutes
assert apply_user_rounding(duration, MockUser2()) == 3600
def test_get_user_rounding_settings(self):
"""Test retrieving user rounding settings"""
class MockUser:
time_rounding_enabled = True
time_rounding_minutes = 10
time_rounding_method = 'up'
settings = get_user_rounding_settings(MockUser())
assert settings['enabled'] is True
assert settings['minutes'] == 10
assert settings['method'] == 'up'
def test_get_user_rounding_settings_with_defaults(self):
"""Test default values when attributes don't exist"""
class MockUser:
pass
settings = get_user_rounding_settings(MockUser())
assert settings['enabled'] is True
assert settings['minutes'] == 1
assert settings['method'] == 'nearest'
class TestFormattingFunctions:
"""Test formatting and helper functions"""
def test_format_rounding_interval(self):
"""Test formatting of rounding intervals"""
assert format_rounding_interval(1) == 'No rounding (exact time)'
assert format_rounding_interval(5) == '5 minutes'
assert format_rounding_interval(15) == '15 minutes'
assert format_rounding_interval(30) == '30 minutes'
assert format_rounding_interval(60) == '1 hour'
assert format_rounding_interval(120) == '2 hours'
def test_get_available_rounding_intervals(self):
"""Test getting available rounding intervals"""
intervals = get_available_rounding_intervals()
assert len(intervals) == 6
assert (1, 'No rounding (exact time)') in intervals
assert (5, '5 minutes') in intervals
assert (60, '1 hour') in intervals
def test_get_available_rounding_methods(self):
"""Test getting available rounding methods"""
methods = get_available_rounding_methods()
assert len(methods) == 3
method_values = [m[0] for m in methods]
assert 'nearest' in method_values
assert 'up' in method_values
assert 'down' in method_values

View File

@@ -0,0 +1,350 @@
"""Model tests for time rounding preferences integration"""
import pytest
from datetime import datetime, timedelta
from app import create_app, db
from app.models import User, Project, TimeEntry
from app.utils.time_rounding import apply_user_rounding
@pytest.fixture
def app():
"""Create application for testing"""
app = create_app()
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
app.config['WTF_CSRF_ENABLED'] = False
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def test_user(app):
"""Create a test user with default rounding preferences"""
with app.app_context():
user = User(username='testuser', role='user')
user.time_rounding_enabled = True
user.time_rounding_minutes = 15
user.time_rounding_method = 'nearest'
db.session.add(user)
db.session.commit()
# Return the user ID instead of the object
user_id = user.id
db.session.expunge_all()
# Re-query the user in a new session
with app.app_context():
return User.query.get(user_id)
@pytest.fixture
def test_project(app, test_user):
"""Create a test project"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project(
name='Test Project',
client='Test Client',
status='active',
created_by_id=user.id
)
db.session.add(project)
db.session.commit()
project_id = project.id
db.session.expunge_all()
with app.app_context():
return Project.query.get(project_id)
class TestUserRoundingPreferences:
"""Test User model rounding preference fields"""
def test_user_has_rounding_fields(self, app, test_user):
"""Test that user model has rounding preference fields"""
with app.app_context():
user = User.query.get(test_user.id)
assert hasattr(user, 'time_rounding_enabled')
assert hasattr(user, 'time_rounding_minutes')
assert hasattr(user, 'time_rounding_method')
def test_user_default_rounding_values(self, app):
"""Test default rounding values for new users"""
with app.app_context():
user = User(username='newuser', role='user')
db.session.add(user)
db.session.commit()
# Defaults should be: enabled=True, minutes=1, method='nearest'
assert user.time_rounding_enabled is True
assert user.time_rounding_minutes == 1
assert user.time_rounding_method == 'nearest'
def test_update_user_rounding_preferences(self, app, test_user):
"""Test updating user rounding preferences"""
with app.app_context():
user = User.query.get(test_user.id)
# Update preferences
user.time_rounding_enabled = False
user.time_rounding_minutes = 30
user.time_rounding_method = 'up'
db.session.commit()
# Verify changes persisted
user_id = user.id
db.session.expunge_all()
user = User.query.get(user_id)
assert user.time_rounding_enabled is False
assert user.time_rounding_minutes == 30
assert user.time_rounding_method == 'up'
def test_multiple_users_different_preferences(self, app):
"""Test that different users can have different rounding preferences"""
with app.app_context():
user1 = User(username='user1', role='user')
user1.time_rounding_enabled = True
user1.time_rounding_minutes = 5
user1.time_rounding_method = 'up'
user2 = User(username='user2', role='user')
user2.time_rounding_enabled = False
user2.time_rounding_minutes = 15
user2.time_rounding_method = 'down'
db.session.add_all([user1, user2])
db.session.commit()
# Verify each user has their own settings
assert user1.time_rounding_minutes == 5
assert user2.time_rounding_minutes == 15
assert user1.time_rounding_method == 'up'
assert user2.time_rounding_method == 'down'
class TestTimeEntryRounding:
"""Test time entry duration calculation with per-user rounding"""
def test_time_entry_uses_user_rounding(self, app, test_user, test_project):
"""Test that time entry uses user's rounding preferences"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Create a time entry with 62 minutes duration
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# User has 15-min nearest rounding, so 62 minutes should round to 60
assert entry.duration_seconds == 3600 # 60 minutes
def test_time_entry_respects_disabled_rounding(self, app, test_user, test_project):
"""Test that rounding is not applied when disabled"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Disable rounding for user
user.time_rounding_enabled = False
db.session.commit()
# Create a time entry with 62 minutes duration
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62, seconds=30)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# With rounding disabled, should be exact: 62.5 minutes = 3750 seconds
assert entry.duration_seconds == 3750
def test_time_entry_round_up_method(self, app, test_user, test_project):
"""Test time entry with 'up' rounding method"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Set to round up with 15-minute intervals
user.time_rounding_method = 'up'
db.session.commit()
# Create entry with 61 minutes (should round up to 75)
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=61)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# 61 minutes rounds up to 75 minutes (next 15-min interval)
assert entry.duration_seconds == 4500 # 75 minutes
def test_time_entry_round_down_method(self, app, test_user, test_project):
"""Test time entry with 'down' rounding method"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Set to round down with 15-minute intervals
user.time_rounding_method = 'down'
db.session.commit()
# Create entry with 74 minutes (should round down to 60)
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=74)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# 74 minutes rounds down to 60 minutes
assert entry.duration_seconds == 3600 # 60 minutes
def test_time_entry_different_intervals(self, app, test_user, test_project):
"""Test time entries with different rounding intervals"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
# Test 5-minute rounding
user.time_rounding_minutes = 5
db.session.commit()
entry1 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry1)
db.session.flush()
# 62 minutes rounds to 60 with 5-min intervals
assert entry1.duration_seconds == 3600
# Test 30-minute rounding
user.time_rounding_minutes = 30
db.session.commit()
entry2 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry2)
db.session.flush()
# 62 minutes rounds to 60 with 30-min intervals
assert entry2.duration_seconds == 3600
def test_stop_timer_applies_rounding(self, app, test_user, test_project):
"""Test that stopping a timer applies user's rounding preferences"""
with app.app_context():
user = User.query.get(test_user.id)
project = Project.query.get(test_project.id)
# Create an active timer
start_time = datetime(2025, 1, 1, 10, 0, 0)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=None
)
db.session.add(entry)
db.session.commit()
# Stop the timer after 62 minutes
end_time = start_time + timedelta(minutes=62)
entry.stop_timer(end_time=end_time)
# Should be rounded to 60 minutes (user has 15-min nearest rounding)
assert entry.duration_seconds == 3600
class TestBackwardCompatibility:
"""Test backward compatibility with global rounding settings"""
def test_fallback_to_global_rounding_without_user_preferences(self, app, test_project):
"""Test that system falls back to global rounding if user prefs don't exist"""
with app.app_context():
# Create a user without rounding preferences (simulating old database)
user = User(username='olduser', role='user')
db.session.add(user)
db.session.flush()
# Remove the new attributes to simulate old schema
if hasattr(user, 'time_rounding_enabled'):
delattr(user, 'time_rounding_enabled')
if hasattr(user, 'time_rounding_minutes'):
delattr(user, 'time_rounding_minutes')
if hasattr(user, 'time_rounding_method'):
delattr(user, 'time_rounding_method')
project = Project.query.get(test_project.id)
# Create a time entry - should fall back to global rounding
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
# Should use global rounding (Config.ROUNDING_MINUTES, default is 1)
# With global rounding = 1, duration should be exact
assert entry.duration_seconds == 3720 # 62 minutes exactly

View File

@@ -0,0 +1,405 @@
"""Smoke tests for time rounding preferences feature - end-to-end testing"""
import pytest
from datetime import datetime, timedelta
from app import create_app, db
from app.models import User, Project, TimeEntry
@pytest.fixture
def app():
"""Create application for testing"""
app = create_app()
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
app.config['WTF_CSRF_ENABLED'] = False
app.config['SERVER_NAME'] = 'localhost'
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def authenticated_user(app, client):
"""Create and authenticate a test user"""
with app.app_context():
user = User(username='smoketest', role='user', email='smoke@test.com')
user.time_rounding_enabled = True
user.time_rounding_minutes = 15
user.time_rounding_method = 'nearest'
db.session.add(user)
project = Project(
name='Smoke Test Project',
client='Smoke Test Client',
status='active',
created_by_id=1
)
db.session.add(project)
db.session.commit()
user_id = user.id
project_id = project.id
# Simulate login
with client.session_transaction() as sess:
sess['user_id'] = user_id
sess['_fresh'] = True
return {'user_id': user_id, 'project_id': project_id}
class TestTimeRoundingFeatureSmokeTests:
"""High-level smoke tests for the time rounding feature"""
def test_user_can_view_rounding_settings(self, app, client, authenticated_user):
"""Test that user can access the settings page with rounding options"""
with app.test_request_context():
response = client.get('/settings')
# Should be able to access settings page
assert response.status_code in [200, 302] # 302 if redirect to login
def test_user_can_update_rounding_preferences(self, app, client, authenticated_user):
"""Test that user can update their rounding preferences"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
# Change preferences
user.time_rounding_enabled = False
user.time_rounding_minutes = 30
user.time_rounding_method = 'up'
db.session.commit()
# Verify changes were saved
db.session.expunge_all()
user = User.query.get(authenticated_user['user_id'])
assert user.time_rounding_enabled is False
assert user.time_rounding_minutes == 30
assert user.time_rounding_method == 'up'
def test_time_entry_reflects_user_rounding_preferences(self, app, authenticated_user):
"""Test that creating a time entry applies user's rounding preferences"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Create a time entry with 62 minutes
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.commit()
# User has 15-min nearest rounding, so 62 -> 60 minutes
assert entry.duration_seconds == 3600
assert entry.duration_hours == 1.0
def test_different_users_have_independent_rounding(self, app):
"""Test that different users can have different rounding settings"""
with app.app_context():
# Create two users with different preferences
user1 = User(username='user1', role='user')
user1.time_rounding_enabled = True
user1.time_rounding_minutes = 5
user1.time_rounding_method = 'nearest'
user2 = User(username='user2', role='user')
user2.time_rounding_enabled = True
user2.time_rounding_minutes = 30
user2.time_rounding_method = 'up'
db.session.add_all([user1, user2])
db.session.commit()
# Create a project
project = Project(
name='Test Project',
client='Test Client',
status='active',
created_by_id=user1.id
)
db.session.add(project)
db.session.commit()
# Create identical time entries for both users
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
entry1 = TimeEntry(
user_id=user1.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
entry2 = TimeEntry(
user_id=user2.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add_all([entry1, entry2])
db.session.commit()
# User1 (5-min nearest): 62 -> 60 minutes
assert entry1.duration_seconds == 3600
# User2 (30-min up): 62 -> 90 minutes
assert entry2.duration_seconds == 5400
def test_disabling_rounding_uses_exact_time(self, app, authenticated_user):
"""Test that disabling rounding results in exact time tracking"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Disable rounding
user.time_rounding_enabled = False
db.session.commit()
# Create entry with odd duration
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62, seconds=37)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.commit()
# Should be exact: 62 minutes 37 seconds = 3757 seconds
assert entry.duration_seconds == 3757
def test_rounding_with_various_intervals(self, app, authenticated_user):
"""Test that all rounding intervals work correctly"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Test duration: 37 minutes
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=37)
test_cases = [
(1, 2220), # No rounding: 37 minutes
(5, 2100), # 5-min: 37 -> 35 minutes
(10, 2400), # 10-min: 37 -> 40 minutes
(15, 2700), # 15-min: 37 -> 45 minutes
(30, 1800), # 30-min: 37 -> 30 minutes
(60, 3600), # 60-min: 37 -> 60 minutes (1 hour)
]
for interval, expected_seconds in test_cases:
user.time_rounding_minutes = interval
user.time_rounding_method = 'nearest'
db.session.commit()
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.flush()
assert entry.duration_seconds == expected_seconds, \
f"Failed for {interval}-minute rounding: expected {expected_seconds}, got {entry.duration_seconds}"
db.session.rollback()
def test_rounding_methods_comparison(self, app, authenticated_user):
"""Test that different rounding methods produce different results"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Test with 62 minutes and 15-min intervals
start_time = datetime(2025, 1, 1, 10, 0, 0)
end_time = start_time + timedelta(minutes=62)
user.time_rounding_minutes = 15
# Test 'nearest' method
user.time_rounding_method = 'nearest'
db.session.commit()
entry_nearest = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry_nearest)
db.session.flush()
# 62 minutes nearest to 15-min interval -> 60 minutes
assert entry_nearest.duration_seconds == 3600
db.session.rollback()
# Test 'up' method
user.time_rounding_method = 'up'
db.session.commit()
entry_up = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry_up)
db.session.flush()
# 62 minutes rounded up to 15-min interval -> 75 minutes
assert entry_up.duration_seconds == 4500
db.session.rollback()
# Test 'down' method
user.time_rounding_method = 'down'
db.session.commit()
entry_down = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry_down)
db.session.flush()
# 62 minutes rounded down to 15-min interval -> 60 minutes
assert entry_down.duration_seconds == 3600
def test_migration_compatibility(self, app):
"""Test that the feature works after migration"""
with app.app_context():
# Verify that new users get the columns
user = User(username='newuser', role='user')
db.session.add(user)
db.session.commit()
# Check that all fields exist and have correct defaults
assert hasattr(user, 'time_rounding_enabled')
assert hasattr(user, 'time_rounding_minutes')
assert hasattr(user, 'time_rounding_method')
assert user.time_rounding_enabled is True
assert user.time_rounding_minutes == 1
assert user.time_rounding_method == 'nearest'
def test_full_workflow(self, app, authenticated_user):
"""Test complete workflow: set preferences -> create entry -> verify rounding"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Step 1: User sets their rounding preferences
user.time_rounding_enabled = True
user.time_rounding_minutes = 10
user.time_rounding_method = 'up'
db.session.commit()
# Step 2: User starts a timer
start_time = datetime(2025, 1, 1, 9, 0, 0)
timer = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=None # Active timer
)
db.session.add(timer)
db.session.commit()
# Verify timer is active
assert timer.is_active is True
assert timer.duration_seconds is None
# Step 3: User stops the timer after 23 minutes
end_time = start_time + timedelta(minutes=23)
timer.stop_timer(end_time=end_time)
# Step 4: Verify the duration was rounded correctly
# With 10-min 'up' rounding, 23 minutes should round up to 30 minutes
assert timer.duration_seconds == 1800 # 30 minutes
assert timer.is_active is False
# Step 5: Verify the entry is queryable with correct rounded duration
db.session.expunge_all()
saved_entry = TimeEntry.query.get(timer.id)
assert saved_entry.duration_seconds == 1800
assert saved_entry.duration_hours == 0.5
class TestEdgeCases:
"""Test edge cases and boundary conditions"""
def test_zero_duration_time_entry(self, app, authenticated_user):
"""Test handling of zero-duration entries"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# Create entry with same start and end time
time = datetime(2025, 1, 1, 10, 0, 0)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=time,
end_time=time
)
db.session.add(entry)
db.session.commit()
# Zero duration should stay zero regardless of rounding
assert entry.duration_seconds == 0
def test_very_long_duration(self, app, authenticated_user):
"""Test rounding of very long time entries (multi-day)"""
with app.app_context():
user = User.query.get(authenticated_user['user_id'])
project = Project.query.get(authenticated_user['project_id'])
# 8 hours 7 minutes
start_time = datetime(2025, 1, 1, 9, 0, 0)
end_time = start_time + timedelta(hours=8, minutes=7)
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start_time,
end_time=end_time
)
db.session.add(entry)
db.session.commit()
# User has 15-min nearest rounding
# 487 minutes -> 485 minutes (rounded down to nearest 15)
assert entry.duration_seconds == 29100 # 485 minutes

587
tests/test_weekly_goals.py Normal file
View File

@@ -0,0 +1,587 @@
"""
Test suite for Weekly Time Goals feature.
Tests model creation, calculations, relationships, routes, and business logic.
"""
import pytest
from datetime import datetime, timedelta, date
from app.models import WeeklyTimeGoal, TimeEntry, User, Project
from app import db
# ============================================================================
# WeeklyTimeGoal Model Tests
# ============================================================================
@pytest.mark.unit
@pytest.mark.models
@pytest.mark.smoke
def test_weekly_goal_creation(app, user):
"""Test basic weekly time goal creation."""
with app.app_context():
week_start = date.today() - timedelta(days=date.today().weekday())
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=week_start
)
db.session.add(goal)
db.session.commit()
assert goal.id is not None
assert goal.target_hours == 40.0
assert goal.week_start_date == week_start
assert goal.week_end_date == week_start + timedelta(days=6)
assert goal.status == 'active'
assert goal.created_at is not None
assert goal.updated_at is not None
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_default_week(app, user):
"""Test weekly goal creation with default week (current week)."""
with app.app_context():
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0
)
db.session.add(goal)
db.session.commit()
# Should default to current week's Monday
today = date.today()
expected_week_start = today - timedelta(days=today.weekday())
assert goal.week_start_date == expected_week_start
assert goal.week_end_date == expected_week_start + timedelta(days=6)
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_with_notes(app, user):
"""Test weekly goal with notes."""
with app.app_context():
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=35.0,
notes="Vacation week, reduced hours"
)
db.session.add(goal)
db.session.commit()
assert goal.notes == "Vacation week, reduced hours"
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_actual_hours_calculation(app, user, project):
"""Test calculation of actual hours worked."""
with app.app_context():
week_start = date.today() - timedelta(days=date.today().weekday())
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=week_start
)
db.session.add(goal)
db.session.commit()
# Add time entries for the week
entry1 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.combine(week_start, datetime.min.time()),
end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=8),
duration_seconds=8 * 3600
)
entry2 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.combine(week_start + timedelta(days=1), datetime.min.time()),
end_time=datetime.combine(week_start + timedelta(days=1), datetime.min.time()) + timedelta(hours=7),
duration_seconds=7 * 3600
)
db.session.add_all([entry1, entry2])
db.session.commit()
# Refresh goal to get calculated properties
db.session.refresh(goal)
assert goal.actual_hours == 15.0
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_progress_percentage(app, user, project):
"""Test progress percentage calculation."""
with app.app_context():
week_start = date.today() - timedelta(days=date.today().weekday())
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=week_start
)
db.session.add(goal)
db.session.commit()
# Add time entry
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.combine(week_start, datetime.min.time()),
end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=20),
duration_seconds=20 * 3600
)
db.session.add(entry)
db.session.commit()
db.session.refresh(goal)
# 20 hours out of 40 = 50%
assert goal.progress_percentage == 50.0
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_remaining_hours(app, user, project):
"""Test remaining hours calculation."""
with app.app_context():
week_start = date.today() - timedelta(days=date.today().weekday())
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=week_start
)
db.session.add(goal)
db.session.commit()
# Add time entry
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.combine(week_start, datetime.min.time()),
end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=15),
duration_seconds=15 * 3600
)
db.session.add(entry)
db.session.commit()
db.session.refresh(goal)
assert goal.remaining_hours == 25.0
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_is_completed(app, user, project):
"""Test is_completed property."""
with app.app_context():
week_start = date.today() - timedelta(days=date.today().weekday())
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=20.0,
week_start_date=week_start
)
db.session.add(goal)
db.session.commit()
db.session.refresh(goal)
assert goal.is_completed is False
# Add time entry to complete goal
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.combine(week_start, datetime.min.time()),
end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=20),
duration_seconds=20 * 3600
)
db.session.add(entry)
db.session.commit()
db.session.refresh(goal)
assert goal.is_completed is True
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_average_hours_per_day(app, user, project):
"""Test average hours per day calculation."""
with app.app_context():
week_start = date.today() - timedelta(days=date.today().weekday())
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=week_start
)
db.session.add(goal)
db.session.commit()
# Add time entry for 10 hours
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.combine(week_start, datetime.min.time()),
end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=10),
duration_seconds=10 * 3600
)
db.session.add(entry)
db.session.commit()
db.session.refresh(goal)
# Remaining: 30 hours, Days remaining: depends on current day
if goal.days_remaining > 0:
expected_avg = round(goal.remaining_hours / goal.days_remaining, 2)
assert goal.average_hours_per_day == expected_avg
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_week_label(app, user):
"""Test week label generation."""
with app.app_context():
week_start = date(2024, 1, 1) # A Monday
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=week_start
)
db.session.add(goal)
db.session.commit()
assert "Jan 01" in goal.week_label
assert "Jan 07" in goal.week_label
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_status_update_completed(app, user, project):
"""Test automatic status update to completed."""
with app.app_context():
# Create goal for past week
week_start = date.today() - timedelta(days=14)
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=20.0,
week_start_date=week_start,
status='active'
)
db.session.add(goal)
db.session.commit()
# Add time entry to meet goal
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.combine(week_start, datetime.min.time()),
end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=20),
duration_seconds=20 * 3600
)
db.session.add(entry)
db.session.commit()
goal.update_status()
db.session.commit()
assert goal.status == 'completed'
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_status_update_failed(app, user, project):
"""Test automatic status update to failed."""
with app.app_context():
# Create goal for past week
week_start = date.today() - timedelta(days=14)
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=week_start,
status='active'
)
db.session.add(goal)
db.session.commit()
# Add time entry that doesn't meet goal
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.combine(week_start, datetime.min.time()),
end_time=datetime.combine(week_start, datetime.min.time()) + timedelta(hours=20),
duration_seconds=20 * 3600
)
db.session.add(entry)
db.session.commit()
goal.update_status()
db.session.commit()
assert goal.status == 'failed'
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_get_current_week(app, user):
"""Test getting current week's goal."""
with app.app_context():
# Create goal for current week
today = date.today()
week_start = today - timedelta(days=today.weekday())
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=week_start
)
db.session.add(goal)
db.session.commit()
# Get current week goal
current_goal = WeeklyTimeGoal.get_current_week_goal(user.id)
assert current_goal is not None
assert current_goal.id == goal.id
@pytest.mark.unit
@pytest.mark.models
def test_weekly_goal_to_dict(app, user):
"""Test goal serialization to dictionary."""
with app.app_context():
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
notes="Test notes"
)
db.session.add(goal)
db.session.commit()
goal_dict = goal.to_dict()
assert 'id' in goal_dict
assert 'user_id' in goal_dict
assert 'target_hours' in goal_dict
assert 'actual_hours' in goal_dict
assert 'week_start_date' in goal_dict
assert 'week_end_date' in goal_dict
assert 'status' in goal_dict
assert 'notes' in goal_dict
assert 'progress_percentage' in goal_dict
assert 'remaining_hours' in goal_dict
assert 'is_completed' in goal_dict
assert goal_dict['target_hours'] == 40.0
assert goal_dict['notes'] == "Test notes"
# ============================================================================
# WeeklyTimeGoal Routes Tests
# ============================================================================
@pytest.mark.smoke
def test_weekly_goals_index_page(client, auth_headers):
"""Test weekly goals index page loads."""
response = client.get('/goals', headers=auth_headers)
assert response.status_code == 200
@pytest.mark.smoke
def test_weekly_goals_create_page(client, auth_headers):
"""Test weekly goals create page loads."""
response = client.get('/goals/create', headers=auth_headers)
assert response.status_code == 200
@pytest.mark.smoke
def test_create_weekly_goal_via_form(client, auth_headers, app, user):
"""Test creating a weekly goal via form submission."""
with app.app_context():
data = {
'target_hours': 40.0,
'notes': 'Test goal'
}
response = client.post('/goals/create', data=data, headers=auth_headers, follow_redirects=True)
assert response.status_code == 200
# Check goal was created
goal = WeeklyTimeGoal.query.filter_by(user_id=user.id).first()
assert goal is not None
assert goal.target_hours == 40.0
@pytest.mark.smoke
def test_edit_weekly_goal(client, auth_headers, app, user):
"""Test editing a weekly goal."""
with app.app_context():
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0
)
db.session.add(goal)
db.session.commit()
goal_id = goal.id
# Update goal
data = {
'target_hours': 35.0,
'notes': 'Updated notes',
'status': 'active'
}
response = client.post(f'/goals/{goal_id}/edit', data=data, headers=auth_headers, follow_redirects=True)
assert response.status_code == 200
# Check goal was updated
db.session.refresh(goal)
assert goal.target_hours == 35.0
assert goal.notes == 'Updated notes'
@pytest.mark.smoke
def test_delete_weekly_goal(client, auth_headers, app, user):
"""Test deleting a weekly goal."""
with app.app_context():
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0
)
db.session.add(goal)
db.session.commit()
goal_id = goal.id
# Delete goal
response = client.post(f'/goals/{goal_id}/delete', headers=auth_headers, follow_redirects=True)
assert response.status_code == 200
# Check goal was deleted
deleted_goal = WeeklyTimeGoal.query.get(goal_id)
assert deleted_goal is None
@pytest.mark.smoke
def test_view_weekly_goal(client, auth_headers, app, user):
"""Test viewing a specific weekly goal."""
with app.app_context():
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0
)
db.session.add(goal)
db.session.commit()
goal_id = goal.id
response = client.get(f'/goals/{goal_id}', headers=auth_headers)
assert response.status_code == 200
# ============================================================================
# API Endpoints Tests
# ============================================================================
@pytest.mark.smoke
def test_api_get_current_goal(client, auth_headers, app, user):
"""Test API endpoint for getting current week's goal."""
with app.app_context():
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0
)
db.session.add(goal)
db.session.commit()
response = client.get('/api/goals/current', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert 'target_hours' in data
assert data['target_hours'] == 40.0
@pytest.mark.smoke
def test_api_list_goals(client, auth_headers, app, user):
"""Test API endpoint for listing goals."""
with app.app_context():
# Create multiple goals
for i in range(3):
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=date.today() - timedelta(weeks=i, days=date.today().weekday())
)
db.session.add(goal)
db.session.commit()
response = client.get('/api/goals', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert isinstance(data, list)
assert len(data) == 3
@pytest.mark.smoke
def test_api_get_goal_stats(client, auth_headers, app, user, project):
"""Test API endpoint for goal statistics."""
with app.app_context():
# Create goals with different statuses
week_start = date.today() - timedelta(days=21)
for i, status in enumerate(['completed', 'failed', 'active']):
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0,
week_start_date=week_start + timedelta(weeks=i),
status=status
)
db.session.add(goal)
db.session.commit()
response = client.get('/api/goals/stats', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert 'total_goals' in data
assert 'completed' in data
assert 'failed' in data
assert 'completion_rate' in data
assert data['total_goals'] == 3
assert data['completed'] == 1
assert data['failed'] == 1
@pytest.mark.unit
def test_weekly_goal_user_relationship(app, user):
"""Test weekly goal user relationship."""
with app.app_context():
goal = WeeklyTimeGoal(
user_id=user.id,
target_hours=40.0
)
db.session.add(goal)
db.session.commit()
db.session.refresh(goal)
assert goal.user is not None
assert goal.user.id == user.id
@pytest.mark.unit
def test_user_has_weekly_goals_relationship(app, user):
"""Test that user has weekly_goals relationship."""
with app.app_context():
# Re-query the user to ensure it's in the current session
from app.models import User
user_obj = User.query.get(user.id)
goal1 = WeeklyTimeGoal(user_id=user_obj.id, target_hours=40.0)
goal2 = WeeklyTimeGoal(
user_id=user_obj.id,
target_hours=35.0,
week_start_date=date.today() - timedelta(weeks=1, days=date.today().weekday())
)
db.session.add_all([goal1, goal2])
db.session.commit()
db.session.refresh(user_obj)
assert user_obj.weekly_goals.count() >= 2