mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2025-12-30 15:49:44 -06:00
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:
@@ -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:
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
98
app/templates/projects/archive.html
Normal file
98
app/templates/projects/archive.html
Normal 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 %}
|
||||
|
||||
@@ -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(){
|
||||
|
||||
@@ -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">
|
||||
|
||||
567
docs/PROJECT_ARCHIVING_GUIDE.md
Normal file
567
docs/PROJECT_ARCHIVING_GUIDE.md
Normal 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+
|
||||
|
||||
96
migrations/versions/026_add_project_archiving_metadata.py
Normal file
96
migrations/versions/026_add_project_archiving_metadata.py
Normal 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}")
|
||||
|
||||
527
tests/test_project_archiving.py
Normal file
527
tests/test_project_archiving.py
Normal 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
|
||||
|
||||
427
tests/test_project_archiving_models.py
Normal file
427
tests/test_project_archiving_models.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user