diff --git a/app/models/project.py b/app/models/project.py index f37e720..13eb1eb 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -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: diff --git a/app/routes/projects.py b/app/routes/projects.py index fe535bf..82b94f5 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -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//archive', methods=['POST']) +@projects_bp.route('/projects//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//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)}") diff --git a/app/routes/timer.py b/app/routes/timer.py index 136e793..08407b9 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -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) diff --git a/app/templates/projects/archive.html b/app/templates/projects/archive.html new file mode 100644 index 0000000..d00a852 --- /dev/null +++ b/app/templates/projects/archive.html @@ -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 +) }} + +
+
+
+
+ +
+

{{ _('What happens when you archive a project?') }}

+
    +
  • {{ _('The project will be hidden from active project lists') }}
  • +
  • {{ _('No new time entries can be added to this project') }}
  • +
  • {{ _('Existing data and time entries are preserved') }}
  • +
  • {{ _('You can unarchive the project later if needed') }}
  • +
+
+
+
+ +
+ + +
+
+ + +

+ {{ _('Adding a reason helps with project organization and future reference.') }} +

+
+ + +
+ +
+ + + + + +
+
+
+ +
+ + {{ _('Cancel') }} + + +
+
+
+
+ + +{% endblock %} + diff --git a/app/templates/projects/list.html b/app/templates/projects/list.html index 0faac94..6655c08 100644 --- a/app/templates/projects/list.html +++ b/app/templates/projects/list.html @@ -69,7 +69,7 @@ @@ -185,6 +185,7 @@ -
- - -
+ {{ _('Archive') }} {% elif project.status == 'inactive' %}
-
- - -
+ {{ _('Archive') }} {% else %}
@@ -84,6 +78,29 @@

Billing

{{ 'Billable' if project.billable else 'Not Billable' }} {% if project.hourly_rate %}({{ "%.2f"|format(project.hourly_rate) }}/hr){% endif %}

+ {% if project.is_archived and project.archived_at %} +
+

{{ _('Archive Information') }}

+
+
+ {{ _('Archived on:') }} + {{ project.archived_at.strftime('%Y-%m-%d %H:%M') }} +
+ {% if project.archived_by_user %} +
+ {{ _('Archived by:') }} + {{ project.archived_by_user.full_name or project.archived_by_user.username }} +
+ {% endif %} + {% if project.archived_reason %} +
+ {{ _('Reason:') }} +

{{ project.archived_reason }}

+
+ {% endif %} +
+
+ {% endif %}
diff --git a/docs/PROJECT_ARCHIVING_GUIDE.md b/docs/PROJECT_ARCHIVING_GUIDE.md new file mode 100644 index 0000000..c6393bb --- /dev/null +++ b/docs/PROJECT_ARCHIVING_GUIDE.md @@ -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+ + diff --git a/migrations/versions/026_add_project_archiving_metadata.py b/migrations/versions/026_add_project_archiving_metadata.py new file mode 100644 index 0000000..db426d1 --- /dev/null +++ b/migrations/versions/026_add_project_archiving_metadata.py @@ -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}") + diff --git a/tests/test_project_archiving.py b/tests/test_project_archiving.py new file mode 100644 index 0000000..c9d57c8 --- /dev/null +++ b/tests/test_project_archiving.py @@ -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 + diff --git a/tests/test_project_archiving_models.py b/tests/test_project_archiving_models.py new file mode 100644 index 0000000..b050cd7 --- /dev/null +++ b/tests/test_project_archiving_models.py @@ -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 +