mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-07 20:20:30 -06:00
feat(tasks): add activity log, Markdown editor, and dark-mode polish
Backend - Add TaskActivity model to record start/pause/review/complete/cancel/reopen - Preserve started_at when reopening tasks; keep timestamps in app-local time - Expose recent activities on task detail view Migrations - Add 004_add_task_activities_table (FKs + indexes) Task detail (UI) - Move Description into its own card and render as Markdown (markdown + bleach) - Restyle Quick Actions to use dashboard btn-action styles - Add custom confirmation modal + tooltips - Recent Time Entries: use dashboard action buttons, add progressive “Show more/less” (10 per click) Tasks list - Fix dark mode for filter UI (inputs, selects, input-group-text, checkboxes) Create/Edit task - Integrate EasyMDE Markdown editor with toolbar - Strong dark-theme overrides (toolbar, editor, preview, status bar, tokens) - Prevent unintended side-by-side persistence - Align “Current Task Info” dark-mode styles with task detail CSS - Add dark-mode tints for action buttons, tooltip light theme in dark mode - Editor layout polish (padding, focus ring, gutters, selection) - Quick actions layout: compact horizontal group Deps - Add: markdown, bleach Run - flask db upgrade # applies 004_add_task_activities_table
This commit is contained in:
@@ -200,7 +200,7 @@ def create_app(config=None):
|
||||
def initialize_database():
|
||||
try:
|
||||
# Import models to ensure they are registered
|
||||
from app.models import User, Project, TimeEntry, Task, Settings
|
||||
from app.models import User, Project, TimeEntry, Task, Settings, TaskActivity
|
||||
|
||||
# Create database tables
|
||||
db.create_all()
|
||||
@@ -322,7 +322,7 @@ def init_database(app):
|
||||
with app.app_context():
|
||||
try:
|
||||
# Import models to ensure they are registered
|
||||
from app.models import User, Project, TimeEntry, Task, Settings
|
||||
from app.models import User, Project, TimeEntry, Task, Settings, TaskActivity
|
||||
|
||||
# Create database tables
|
||||
db.create_all()
|
||||
|
||||
@@ -5,5 +5,6 @@ from .task import Task
|
||||
from .settings import Settings
|
||||
from .invoice import Invoice, InvoiceItem
|
||||
from .client import Client
|
||||
from .task_activity import TaskActivity
|
||||
|
||||
__all__ = ['User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client']
|
||||
__all__ = ['User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client', 'TaskActivity']
|
||||
|
||||
28
app/models/task_activity.py
Normal file
28
app/models/task_activity.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from app import db
|
||||
from app.utils.timezone import now_in_app_timezone
|
||||
|
||||
|
||||
class TaskActivity(db.Model):
|
||||
"""Lightweight audit log for significant task events."""
|
||||
__tablename__ = 'task_activities'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=False, index=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
|
||||
event = db.Column(db.String(50), nullable=False, index=True)
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False, index=True)
|
||||
|
||||
task = db.relationship('Task', backref=db.backref('activities', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
user = db.relationship('User')
|
||||
|
||||
def __init__(self, task_id, event, user_id=None, details=None):
|
||||
self.task_id = task_id
|
||||
self.user_id = user_id
|
||||
self.event = event
|
||||
self.details = details
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TaskActivity task={self.task_id} event={self.event}>'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import Task, Project, User, TimeEntry
|
||||
from app.models import Task, Project, User, TimeEntry, TaskActivity
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from app.utils.db import safe_commit
|
||||
@@ -166,8 +166,10 @@ def view_task(task_id):
|
||||
|
||||
# Get time entries for this task
|
||||
time_entries = task.time_entries.order_by(TimeEntry.start_time.desc()).all()
|
||||
# Recent activity entries
|
||||
activities = task.activities.order_by(TaskActivity.created_at.desc()).limit(20).all()
|
||||
|
||||
return render_template('tasks/view.html', task=task, time_entries=time_entries)
|
||||
return render_template('tasks/view.html', task=task, time_entries=time_entries, activities=activities)
|
||||
|
||||
@tasks_bp.route('/tasks/<int:task_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -221,19 +223,40 @@ def edit_task(task_id):
|
||||
valid_statuses = ['todo', 'in_progress', 'review', 'done', 'cancelled']
|
||||
if selected_status and selected_status in valid_statuses and selected_status != task.status:
|
||||
try:
|
||||
previous_status = task.status
|
||||
if selected_status == 'in_progress':
|
||||
task.start_task()
|
||||
# If reopening from done, preserve started_at
|
||||
if task.status == 'done':
|
||||
task.completed_at = None
|
||||
task.status = 'in_progress'
|
||||
if not task.started_at:
|
||||
task.started_at = now_in_app_timezone()
|
||||
task.updated_at = now_in_app_timezone()
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress'))
|
||||
if not safe_commit('edit_task_reopen_in_progress', {'task_id': task.id}):
|
||||
flash('Could not update status due to a database error. Please check server logs.', 'error')
|
||||
return render_template('tasks/edit.html', task=task)
|
||||
else:
|
||||
task.start_task()
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress"))
|
||||
safe_commit('log_task_start_from_edit', {'task_id': task.id})
|
||||
elif selected_status == 'done':
|
||||
task.complete_task()
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='complete', details='Task completed'))
|
||||
safe_commit('log_task_complete_from_edit', {'task_id': task.id})
|
||||
elif selected_status == 'cancelled':
|
||||
task.cancel_task()
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='cancel', details='Task cancelled'))
|
||||
safe_commit('log_task_cancel_from_edit', {'task_id': task.id})
|
||||
else:
|
||||
# Reopen or move to non-special states
|
||||
# Clear completed_at if reopening from done
|
||||
if task.status == 'done' and selected_status in ['todo', 'in_progress', 'review']:
|
||||
if task.status == 'done' and selected_status in ['todo', 'review']:
|
||||
task.completed_at = None
|
||||
task.status = selected_status
|
||||
task.updated_at = now_in_app_timezone()
|
||||
event_name = 'reopen' if previous_status == 'done' and selected_status in ['todo', 'review'] else ('pause' if selected_status == 'todo' else ('review' if selected_status == 'review' else 'status_change'))
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {selected_status}"))
|
||||
if not safe_commit('edit_task_status_change', {'task_id': task.id, 'status': selected_status}):
|
||||
flash('Could not update status due to a database error. Please check server logs.', 'error')
|
||||
return render_template('tasks/edit.html', task=task)
|
||||
@@ -286,21 +309,40 @@ def update_task_status(task_id):
|
||||
if not task.started_at:
|
||||
task.started_at = now_in_app_timezone()
|
||||
task.updated_at = now_in_app_timezone()
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress'))
|
||||
if not safe_commit('update_task_status_reopen_in_progress', {'task_id': task.id, 'status': new_status}):
|
||||
flash('Could not update status due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('tasks.view_task', task_id=task.id))
|
||||
else:
|
||||
previous_status = task.status
|
||||
task.start_task()
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress"))
|
||||
safe_commit('log_task_start', {'task_id': task.id})
|
||||
elif new_status == 'done':
|
||||
task.complete_task()
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='complete', details='Task completed'))
|
||||
safe_commit('log_task_complete', {'task_id': task.id})
|
||||
elif new_status == 'cancelled':
|
||||
task.cancel_task()
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='cancel', details='Task cancelled'))
|
||||
safe_commit('log_task_cancel', {'task_id': task.id})
|
||||
else:
|
||||
# For other transitions, handle reopening from done and local timestamps
|
||||
if task.status == 'done' and new_status in ['todo', 'review']:
|
||||
task.completed_at = None
|
||||
previous_status = task.status
|
||||
task.status = new_status
|
||||
task.updated_at = now_in_app_timezone()
|
||||
# Log pause or review or generic change
|
||||
if previous_status == 'done' and new_status in ['todo', 'review']:
|
||||
event_name = 'reopen'
|
||||
else:
|
||||
event_map = {
|
||||
'todo': 'pause',
|
||||
'review': 'review',
|
||||
}
|
||||
event_name = event_map.get(new_status, 'status_change')
|
||||
db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {new_status}"))
|
||||
if not safe_commit('update_task_status', {'task_id': task.id, 'status': new_status}):
|
||||
flash('Could not update status due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('tasks.view_task', task_id=task.id))
|
||||
|
||||
@@ -376,9 +376,120 @@ main {
|
||||
background: #0f172a;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
[data-theme="dark"] textarea.form-control {
|
||||
background: #0f172a;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* EasyMDE (Markdown editor) dark theme overrides */
|
||||
[data-theme="dark"] .EasyMDEContainer .editor-toolbar {
|
||||
background: #0b1220 !important;
|
||||
border-color: var(--border-color) !important;
|
||||
box-shadow: inset 0 -1px 0 rgba(255,255,255,0.03);
|
||||
}
|
||||
[data-theme="dark"] .EasyMDEContainer .editor-toolbar a {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
[data-theme="dark"] .EasyMDEContainer .editor-toolbar a:hover,
|
||||
[data-theme="dark"] .EasyMDEContainer .editor-toolbar a.active {
|
||||
background: #111827 !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="dark"] .EasyMDEContainer .CodeMirror,
|
||||
[data-theme="dark"] .EasyMDEContainer .CodeMirror-scroll,
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-s-easymde.CodeMirror,
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .CodeMirror-scroll {
|
||||
background: #0f172a !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-s-easymde.CodeMirror {
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-top: none !important; /* aligns with toolbar border */
|
||||
}
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .CodeMirror-gutters {
|
||||
background: #0f172a !important;
|
||||
border-right: 1px solid var(--border-color) !important;
|
||||
}
|
||||
[data-theme="dark"] .EasyMDEContainer .CodeMirror-selected {
|
||||
background: rgba(59,130,246,0.25) !important;
|
||||
}
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .cm-url { color: #93c5fd !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .cm-code { color: #fca5a5 !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-s-easymde .cm-hr { color: #475569 !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .CodeMirror pre { color: var(--text-primary) !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .CodeMirror-cursor { border-left-color: #e5e7eb !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .CodeMirror .CodeMirror-placeholder { color: #64748b !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .editor-statusbar {
|
||||
background: #0b1220 !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
[data-theme="dark"] .EasyMDEContainer .editor-preview,
|
||||
[data-theme="dark"] .EasyMDEContainer .editor-preview-side {
|
||||
background: #0f172a !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
/* Token colors for dark mode (readability) */
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-header { color: #93c5fd !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-strong { color: #e5e7eb !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-em { color: #fca5a5 !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-quote { color: #a7f3d0 !important; }
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-link { color: #60a5fa !important; text-decoration: underline; }
|
||||
[data-theme="dark"] .EasyMDEContainer .cm-formatting-header { color: #60a5fa !important; }
|
||||
|
||||
/* EasyMDE global layout enhancements (both themes) */
|
||||
.EasyMDEContainer {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
.EasyMDEContainer .editor-toolbar {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.EasyMDEContainer .editor-toolbar a {
|
||||
border-radius: 6px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.EasyMDEContainer .editor-toolbar .separator {
|
||||
border-left: 1px solid var(--border-color);
|
||||
margin: 0 0.35rem;
|
||||
}
|
||||
.EasyMDEContainer .CodeMirror {
|
||||
min-height: 260px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.EasyMDEContainer .editor-preview-side { max-width: 50%; }
|
||||
.EasyMDEContainer .CodeMirror, .EasyMDEContainer .editor-preview-side { box-sizing: border-box; }
|
||||
.EasyMDEContainer .CodeMirror-scroll {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.EasyMDEContainer .CodeMirror-focused {
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.15);
|
||||
}
|
||||
.EasyMDEContainer .editor-statusbar {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
[data-theme="dark"] .input-group-text {
|
||||
background: #111827;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
[data-theme="dark"] .form-text { color: var(--text-muted); }
|
||||
[data-theme="dark"] .form-control::placeholder { color: #64748b; }
|
||||
[data-theme="dark"] .form-select option { background: #0f172a; color: var(--text-primary); }
|
||||
[data-theme="dark"] .form-check-input {
|
||||
background-color: #0f172a;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
@@ -505,31 +616,36 @@ main {
|
||||
background: #0f172a;
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
|
||||
}
|
||||
[data-theme="dark"] .btn-action:hover { background: #111827; }
|
||||
[data-theme="dark"] .btn-action--view {
|
||||
color: #93c5fd;
|
||||
color: #60a5fa;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
[data-theme="dark"] .btn-action--view:hover { background: rgba(59, 130, 246, 0.08); }
|
||||
[data-theme="dark"] .btn-action--view:hover { background: rgba(59, 130, 246, 0.12); }
|
||||
[data-theme="dark"] .btn-action--edit {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
[data-theme="dark"] .btn-action--edit:hover { background: #111827; }
|
||||
[data-theme="dark"] .btn-action--danger {
|
||||
color: #fca5a5;
|
||||
border-color: rgba(220, 38, 38, 0.45);
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-color: rgba(220, 38, 38, 0.35);
|
||||
}
|
||||
[data-theme="dark"] .btn-action--danger:hover { background: rgba(220, 38, 38, 0.12); }
|
||||
[data-theme="dark"] .btn-action--success {
|
||||
color: #86efac;
|
||||
border-color: rgba(16, 185, 129, 0.45);
|
||||
color: #22c55e;
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.35);
|
||||
}
|
||||
[data-theme="dark"] .btn-action--success:hover { background: rgba(16, 185, 129, 0.12); }
|
||||
[data-theme="dark"] .btn-action--success:hover { background: rgba(16, 185, 129, 0.14); }
|
||||
[data-theme="dark"] .btn-action--warning {
|
||||
color: #fcd34d;
|
||||
border-color: rgba(245, 158, 11, 0.55);
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.06);
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
[data-theme="dark"] .btn-action--warning:hover { background: rgba(245, 158, 11, 0.12); }
|
||||
[data-theme="dark"] .btn-action--more {
|
||||
@@ -1371,3 +1487,26 @@ h6 { font-size: 1rem; }
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Tooltip overrides to keep light tooltips in dark mode */
|
||||
[data-theme="dark"] .tooltip .tooltip-inner {
|
||||
background-color: #f9fafb; /* light */
|
||||
color: #111827; /* dark text */
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
[data-theme="dark"] .tooltip.bs-tooltip-top .tooltip-arrow::before,
|
||||
[data-theme="dark"] .tooltip.bs-tooltip-auto[data-popper-placement^="top"] .tooltip-arrow::before {
|
||||
border-top-color: #f9fafb;
|
||||
}
|
||||
[data-theme="dark"] .tooltip.bs-tooltip-bottom .tooltip-arrow::before,
|
||||
[data-theme="dark"] .tooltip.bs-tooltip-auto[data-popper-placement^="bottom"] .tooltip-arrow::before {
|
||||
border-bottom-color: #f9fafb;
|
||||
}
|
||||
[data-theme="dark"] .tooltip.bs-tooltip-start .tooltip-arrow::before,
|
||||
[data-theme="dark"] .tooltip.bs-tooltip-auto[data-popper-placement^="left"] .tooltip-arrow::before {
|
||||
border-left-color: #f9fafb;
|
||||
}
|
||||
[data-theme="dark"] .tooltip.bs-tooltip-end .tooltip-arrow::before,
|
||||
[data-theme="dark"] .tooltip.bs-tooltip-auto[data-popper-placement^="right"] .tooltip-arrow::before {
|
||||
border-right-color: #f9fafb;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,11 @@
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-4">
|
||||
<label for="description" class="form-label fw-semibold">
|
||||
<label for="description" class="form-label fw-semibold d-flex align-items-center justify-content-between">
|
||||
<i class="fas fa-align-left me-2 text-primary"></i>Description
|
||||
<small class="text-muted">Supports Markdown</small>
|
||||
</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="4"
|
||||
<textarea class="form-control" id="description" name="description" rows="6"
|
||||
placeholder="Provide detailed information about the task, requirements, and any specific instructions...">{{ request.form.get('description', '') }}</textarea>
|
||||
<small class="form-text text-muted">Optional: Add context, requirements, or specific instructions for the task</small>
|
||||
</div>
|
||||
@@ -419,12 +420,30 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- EasyMDE Markdown Editor -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
|
||||
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Form validation and enhancement
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('createTaskForm');
|
||||
const nameInput = document.getElementById('name');
|
||||
const descriptionInput = document.getElementById('description');
|
||||
|
||||
// Initialize EasyMDE for Markdown input
|
||||
if (descriptionInput && window.EasyMDE) {
|
||||
const easyMDE = new EasyMDE({
|
||||
element: descriptionInput,
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
toolbar: [
|
||||
'bold','italic','heading','|','unordered-list','ordered-list','|','link','quote','code','table','horizontal-rule','|','preview','guide'
|
||||
],
|
||||
renderingConfig: { singleLineBreaks: false },
|
||||
minHeight: '260px'
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-resize description textarea
|
||||
if (descriptionInput) {
|
||||
|
||||
@@ -46,11 +46,14 @@
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-4">
|
||||
<label for="description" class="form-label fw-semibold">
|
||||
<i class="fas fa-align-left me-2 text-primary"></i>Description
|
||||
<label for="description" class="form-label fw-semibold d-flex align-items-center justify-content-between">
|
||||
<span><i class="fas fa-align-left me-2 text-primary"></i>Description</span>
|
||||
<small class="text-muted">Markdown supported</small>
|
||||
</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="4"
|
||||
placeholder="Provide detailed information about the task, requirements, and any specific instructions...">{{ task.description or '' }}</textarea>
|
||||
<div class="markdown-editor-wrapper">
|
||||
<textarea class="form-control" id="description" name="description" rows="8"
|
||||
placeholder="Provide detailed information about the task, requirements, and any specific instructions...">{{ task.description or '' }}</textarea>
|
||||
</div>
|
||||
<small class="form-text text-muted">Optional: Add context, requirements, or specific instructions for the task</small>
|
||||
</div>
|
||||
|
||||
@@ -400,6 +403,16 @@
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Dark mode for task info blocks to match view page */
|
||||
[data-theme="dark"] .task-info-item,
|
||||
[data-theme="dark"] .tip-item {
|
||||
background-color: #0f172a;
|
||||
}
|
||||
[data-theme="dark"] .task-info-item:hover,
|
||||
[data-theme="dark"] .tip-item:hover {
|
||||
background-color: #0b1220;
|
||||
}
|
||||
|
||||
/* Form Styling */
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
@@ -415,6 +428,12 @@
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
/* Markdown editor wrapper to align with cards */
|
||||
.markdown-editor-wrapper {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
@@ -443,6 +462,10 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- EasyMDE Markdown Editor -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
|
||||
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Form validation and enhancement
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -450,12 +473,25 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const nameInput = document.getElementById('name');
|
||||
const descriptionInput = document.getElementById('description');
|
||||
|
||||
// Auto-resize description textarea
|
||||
if (descriptionInput) {
|
||||
descriptionInput.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = (this.scrollHeight) + 'px';
|
||||
// Initialize EasyMDE for Markdown input
|
||||
if (descriptionInput && window.EasyMDE) {
|
||||
const easyMDE = new EasyMDE({
|
||||
element: descriptionInput,
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
toolbar: [
|
||||
'bold','italic','heading','|','unordered-list','ordered-list','|','link','quote','code','table','horizontal-rule','|','preview','guide'
|
||||
],
|
||||
renderingConfig: { singleLineBreaks: false },
|
||||
minHeight: '260px'
|
||||
});
|
||||
// Ensure side-by-side is not persisted from previous sessions
|
||||
try {
|
||||
const container = descriptionInput.closest('.EasyMDEContainer');
|
||||
if (container && container.classList.contains('side-by-side')) {
|
||||
easyMDE.toggleSideBySide();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Form submission enhancement
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h1 class="h2 mb-2">{{ task.name }}</h1>
|
||||
{% if task.description %}
|
||||
<p class="text-muted mb-0">{{ task.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +73,22 @@
|
||||
<div class="row g-4">
|
||||
<!-- Main Task Information -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Task Description -->
|
||||
{% if task.description %}
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-align-left me-2 text-primary"></i>Description
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="task-description-content">
|
||||
{{ task.description | markdown | safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Project Information -->
|
||||
<div class="card mobile-card mb-4">
|
||||
<div class="card-header">
|
||||
@@ -178,8 +191,8 @@
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in time_entries[:5] %}
|
||||
<tbody id="timeEntriesTableBody">
|
||||
{% for entry in time_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.start_time.strftime('%b %d, %Y') }}</td>
|
||||
<td>
|
||||
@@ -191,14 +204,14 @@
|
||||
</td>
|
||||
<td>{{ entry.notes[:50] if entry.notes else '-' }}</td>
|
||||
<td>{{ entry.user.display_name if entry.user else '-' }}</td>
|
||||
<td class="text-end">
|
||||
<td class="text-end pe-4 actions-cell">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Edit">
|
||||
class="btn btn-sm btn-action btn-action--edit touch-target" title="Edit entry">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if current_user.is_admin or entry.user_id == current_user.id %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete"
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--danger touch-target" title="Delete entry"
|
||||
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ task.project.name }}', '{{ entry.duration_formatted }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -210,9 +223,17 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if task.time_entries.count() > 5 %}
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">Showing 5 of {{ task.time_entries.count() }} entries</small>
|
||||
{% if task.time_entries.count() > 10 %}
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<small class="text-muted" id="entriesInfo">Showing 10 of {{ task.time_entries.count() }} entries</small>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="showMoreBtn" onclick="showMoreEntries()">
|
||||
<i class="fas fa-chevron-down me-2"></i>Show more
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary d-none" id="showLessBtn" onclick="showLessEntries()">
|
||||
<i class="fas fa-chevron-up me-2"></i>Show less
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -314,34 +335,38 @@
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<div class="quick-actions d-flex flex-wrap gap-2">
|
||||
{% if task.status == 'todo' %}
|
||||
<button class="btn btn-warning btn-sm" onclick="updateTaskStatus('in_progress')">
|
||||
<i class="fas fa-play me-2"></i>Start Task
|
||||
<button class="btn btn-sm btn-action btn-action--success" onclick="openStatusModal('in_progress')" title="Start Task" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% elif task.status == 'in_progress' %}
|
||||
<button class="btn btn-info btn-sm" onclick="updateTaskStatus('review')">
|
||||
<i class="fas fa-eye me-2"></i>Mark for Review
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="updateTaskStatus('todo')">
|
||||
<i class="fas fa-pause me-2"></i>Pause Task
|
||||
</button>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-action btn-action--view" onclick="openStatusModal('review')" title="Mark for Review" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-action btn-action--warning" onclick="openStatusModal('todo')" title="Pause Task" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-pause"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% elif task.status == 'review' %}
|
||||
<button class="btn btn-success btn-sm" onclick="updateTaskStatus('done')">
|
||||
<i class="fas fa-check me-2"></i>Complete Task
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm" onclick="updateTaskStatus('in_progress')">
|
||||
<i class="fas fa-undo me-2"></i>Back to Progress
|
||||
</button>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-action btn-action--success" onclick="openStatusModal('done')" title="Complete Task" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-action btn-action--edit" onclick="openStatusModal('in_progress')" title="Back to Progress" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% elif task.status == 'done' %}
|
||||
<button class="btn btn-warning btn-sm" onclick="updateTaskStatus('todo')">
|
||||
<i class="fas fa-undo me-2"></i>Reopen Task
|
||||
<button class="btn btn-sm btn-action btn-action--warning" onclick="openStatusModal('todo')" title="Reopen Task" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if task.status != 'done' and task.status != 'cancelled' %}
|
||||
<button class="btn btn-danger btn-sm" onclick="updateTaskStatus('cancelled')">
|
||||
<i class="fas fa-times me-2"></i>Cancel Task
|
||||
<button class="btn btn-sm btn-action btn-action--danger" onclick="openStatusModal('cancelled')" title="Cancel Task" data-bs-toggle="tooltip">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -395,6 +420,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Log -->
|
||||
{% if task.activities.count() > 0 %}
|
||||
<div class="card mobile-card mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-clipboard-list me-2 text-secondary"></i>Activity Log
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Event</th>
|
||||
<th>User</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for act in activities %}
|
||||
<tr>
|
||||
<td>{{ act.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td class="text-capitalize">{{ act.event.replace('_', ' ') }}</td>
|
||||
<td>{{ act.user.display_name if act.user else '-' }}</td>
|
||||
<td class="text-muted">{{ act.details or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -555,29 +615,128 @@
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Dark mode for task details */
|
||||
[data-theme="dark"] .task-detail-item {
|
||||
background-color: #0f172a;
|
||||
}
|
||||
[data-theme="dark"] .task-detail-item:hover {
|
||||
background-color: #0b1220;
|
||||
}
|
||||
[data-theme="dark"] .timeline::before { background-color: #1f2937; }
|
||||
[data-theme="dark"] .timeline-marker { box-shadow: 0 0 0 2px #1f2937; }
|
||||
[data-theme="dark"] .card.mobile-card .card-header h6 { color: #e5e7eb; }
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* Quick actions: prevent full-width buttons, improve spacing */
|
||||
.quick-actions .btn { width: auto; }
|
||||
@media (max-width: 768px) {
|
||||
.quick-actions { gap: 0.5rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function updateTaskStatus(status) {
|
||||
if (confirm('Are you sure you want to update the task status?')) {
|
||||
// Simple form submission approach instead of fetch with CSRF token
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{{ url_for("tasks.update_task_status", task_id=task.id) }}';
|
||||
|
||||
const statusInput = document.createElement('input');
|
||||
statusInput.type = 'hidden';
|
||||
statusInput.name = 'status';
|
||||
statusInput.value = status;
|
||||
|
||||
form.appendChild(statusInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
// Build and submit form without default confirm
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{{ url_for("tasks.update_task_status", task_id=task.id) }}';
|
||||
|
||||
const statusInput = document.createElement('input');
|
||||
statusInput.type = 'hidden';
|
||||
statusInput.name = 'status';
|
||||
statusInput.value = status;
|
||||
|
||||
form.appendChild(statusInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function toggleEntries() { /* deprecated - replaced by showMoreEntries/showLessEntries */ }
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const rows = document.querySelectorAll('#timeEntriesTableBody tr');
|
||||
const total = rows.length;
|
||||
const initial = Math.min(10, total);
|
||||
rows.forEach((row, idx) => row.style.display = (idx < initial ? '' : 'none'));
|
||||
const info = document.getElementById('entriesInfo');
|
||||
if (info && total > 10) info.textContent = `Showing ${initial} of ${total} entries`;
|
||||
|
||||
// Initialize tooltips
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
|
||||
new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
})
|
||||
});
|
||||
|
||||
// Quick Action Modal
|
||||
let pendingStatus = null;
|
||||
function openStatusModal(status) {
|
||||
pendingStatus = status;
|
||||
const titleMap = {
|
||||
'in_progress': 'Start Task',
|
||||
'review': 'Mark for Review',
|
||||
'todo': 'Pause / Reopen Task',
|
||||
'done': 'Complete Task',
|
||||
'cancelled': 'Cancel Task'
|
||||
};
|
||||
const bodyMap = {
|
||||
'in_progress': 'Are you sure you want to start this task now?',
|
||||
'review': 'Move this task to Review?',
|
||||
'todo': 'Move this task to To Do?',
|
||||
'done': 'Mark this task as Completed?',
|
||||
'cancelled': 'Cancel this task? You can reopen it later.'
|
||||
};
|
||||
|
||||
const modalEl = document.getElementById('statusConfirmModal');
|
||||
modalEl.querySelector('.modal-title').textContent = titleMap[status] || 'Confirm Action';
|
||||
modalEl.querySelector('.modal-body-text').textContent = bodyMap[status] || 'Are you sure?';
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function confirmStatusChange() {
|
||||
if (!pendingStatus) return;
|
||||
const btn = document.getElementById('statusConfirmBtn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Working...';
|
||||
}
|
||||
updateTaskStatus(pendingStatus);
|
||||
}
|
||||
|
||||
// Progressive show more/less for time entries
|
||||
function showMoreEntries() {
|
||||
const rows = Array.from(document.querySelectorAll('#timeEntriesTableBody tr'));
|
||||
const info = document.getElementById('entriesInfo');
|
||||
const showMoreBtn = document.getElementById('showMoreBtn');
|
||||
const showLessBtn = document.getElementById('showLessBtn');
|
||||
const currentlyVisible = rows.filter(r => r.style.display !== 'none').length;
|
||||
const toShow = Math.min(currentlyVisible + 10, rows.length);
|
||||
rows.forEach((row, idx) => row.style.display = (idx < toShow ? '' : 'none'));
|
||||
if (info) info.textContent = (toShow < rows.length) ? `Showing ${toShow} of ${rows.length} entries` : `Showing all ${rows.length} entries`;
|
||||
if (toShow >= rows.length) {
|
||||
showMoreBtn.classList.add('d-none');
|
||||
showLessBtn.classList.remove('d-none');
|
||||
} else {
|
||||
showLessBtn.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function showLessEntries() {
|
||||
const rows = Array.from(document.querySelectorAll('#timeEntriesTableBody tr'));
|
||||
const info = document.getElementById('entriesInfo');
|
||||
const showMoreBtn = document.getElementById('showMoreBtn');
|
||||
const showLessBtn = document.getElementById('showLessBtn');
|
||||
const initial = Math.min(10, rows.length);
|
||||
rows.forEach((row, idx) => row.style.display = (idx < initial ? '' : 'none'));
|
||||
if (info) info.textContent = `Showing ${initial} of ${rows.length} entries`;
|
||||
showMoreBtn.classList.remove('d-none');
|
||||
showLessBtn.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Function to show delete time entry modal
|
||||
@@ -607,6 +766,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Status Confirm Modal -->
|
||||
<div class="modal fade" id="statusConfirmModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Action</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-body-text">Are you sure?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</button>
|
||||
<button type="button" id="statusConfirmBtn" class="btn btn-primary" onclick="confirmStatusChange()">
|
||||
<i class="fas fa-check me-2"></i>Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Time Entry Modal -->
|
||||
<div class="modal fade" id="deleteEntryModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
from flask import Blueprint
|
||||
from app.utils.timezone import utc_to_local, format_local_datetime
|
||||
try:
|
||||
import markdown as _md
|
||||
import bleach
|
||||
except Exception:
|
||||
_md = None
|
||||
bleach = None
|
||||
|
||||
def register_template_filters(app):
|
||||
"""Register custom template filters for the application"""
|
||||
@@ -38,3 +44,27 @@ def register_template_filters(app):
|
||||
if text is None:
|
||||
return ""
|
||||
return text.replace('\n', '<br>')
|
||||
|
||||
@app.template_filter('markdown')
|
||||
def markdown_filter(text):
|
||||
"""Render markdown to safe HTML using bleach sanitation."""
|
||||
if not text:
|
||||
return ""
|
||||
if _md is None:
|
||||
# Fallback: escape and basic nl2br
|
||||
try:
|
||||
from markupsafe import escape
|
||||
except Exception:
|
||||
return text
|
||||
return escape(text).replace('\n', '<br>')
|
||||
|
||||
html = _md.markdown(text, extensions=['extra', 'sane_lists', 'smarty'])
|
||||
if bleach is None:
|
||||
return html
|
||||
allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({'p','pre','code','img','h1','h2','h3','h4','h5','h6','table','thead','tbody','tr','th','td','hr','br','ul','ol','li','strong','em','blockquote','a'})
|
||||
allowed_attrs = {
|
||||
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
|
||||
'a': ['href', 'title', 'rel', 'target'],
|
||||
'img': ['src', 'alt', 'title'],
|
||||
}
|
||||
return bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs, strip=True)
|
||||
|
||||
44
migrations/versions/004_add_task_activities_table.py
Normal file
44
migrations/versions/004_add_task_activities_table.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""add task_activities table
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2025-09-07 10:35:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '004'
|
||||
down_revision = '003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'task_activities',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('task_id', sa.Integer(), sa.ForeignKey('tasks.id', ondelete='CASCADE'), nullable=False, index=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
|
||||
sa.Column('event', sa.String(length=50), nullable=False, index=True),
|
||||
sa.Column('details', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
)
|
||||
|
||||
# Explicit indexes (in addition to inline index=True for portability)
|
||||
op.create_index('idx_task_activities_task_id', 'task_activities', ['task_id'])
|
||||
op.create_index('idx_task_activities_user_id', 'task_activities', ['user_id'])
|
||||
op.create_index('idx_task_activities_event', 'task_activities', ['event'])
|
||||
op.create_index('idx_task_activities_created_at', 'task_activities', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('idx_task_activities_created_at', table_name='task_activities')
|
||||
op.drop_index('idx_task_activities_event', table_name='task_activities')
|
||||
op.drop_index('idx_task_activities_user_id', table_name='task_activities')
|
||||
op.drop_index('idx_task_activities_task_id', table_name='task_activities')
|
||||
op.drop_table('task_activities')
|
||||
|
||||
|
||||
@@ -37,3 +37,5 @@ flake8==6.1.0
|
||||
|
||||
# Security
|
||||
cryptography==45.0.6
|
||||
markdown==3.6
|
||||
bleach==6.1.0
|
||||
|
||||
Reference in New Issue
Block a user