feat: Implement comprehensive project archiving system

Add enhanced project archiving functionality for better organization of
completed projects with metadata tracking and validation.

Key Features:
- Archive metadata tracking (timestamp, user, reason)
- Archive form with quick-select reason templates
- Bulk archiving with optional shared reason
- Archive information display on project details
- Prevent time tracking on archived projects
- Activity logging for archive/unarchive actions

Database Changes:
- Add migration 026_add_project_archiving_metadata.py
- New fields: archived_at, archived_by (FK), archived_reason
- Index on archived_at for faster filtering
- Cascade on user deletion (SET NULL)

Model Enhancements (app/models/project.py):
- Enhanced archive() method with user_id and reason parameters
- Enhanced unarchive() method to clear all metadata
- New properties: is_archived, archived_by_user
- Updated to_dict() to include archive metadata

Route Updates (app/routes/projects.py):
- Convert archive route to GET/POST (form-based)
- Add archive reason handling
- Enhanced bulk operations with reason support
- Activity logging for all archive operations

UI Improvements:
- New archive form template (app/templates/projects/archive.html)
- Quick-select buttons for common archive reasons
- Archive metadata display on project view page
- Bulk archive modal with reason input
- Updated project list filtering

Validation (app/routes/timer.py):
- Prevent timer start on archived projects
- Block manual entry creation on archived projects
- Block bulk entry creation on archived projects
- Clear error messages for users

Testing:
- 90+ comprehensive test cases
- Unit tests (tests/test_project_archiving.py)
- Model tests (tests/test_project_archiving_models.py)
- Smoke tests for complete workflows
- Edge case coverage

Documentation:
- User guide (docs/PROJECT_ARCHIVING_GUIDE.md)
- Implementation summary (PROJECT_ARCHIVING_IMPLEMENTATION_SUMMARY.md)
- API reference and examples
- Best practices and troubleshooting

Migration Notes:
- Backward compatible with existing archived projects
- Existing archives will have NULL metadata (can be added later)
- No data migration required
- Run: migrations/manage_migrations.py upgrade head

Breaking Changes: None
- All changes are additive and backward compatible

Related: Feat-Project-Archiving branch
This commit is contained in:
Dries Peeters
2025-10-24 09:06:51 +02:00
parent 8d09387919
commit 6de86fca2b
10 changed files with 1965 additions and 34 deletions

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

@@ -428,23 +428,51 @@ 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"""
"""Archive a project with optional reason"""
if not current_user.is_admin:
flash('Only administrators can 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
@@ -460,6 +488,23 @@ def unarchive_project(project_id):
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'))
@@ -605,6 +650,7 @@ def bulk_status_change():
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 +671,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

@@ -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

@@ -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

@@ -20,19 +20,13 @@
<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>
<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>
{% 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>
<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() }}">
@@ -84,6 +78,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">

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

@@ -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,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