diff --git a/app/__init__.py b/app/__init__.py index 74e4a888..c0a9644e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -459,9 +459,9 @@ def apply_security_headers(response): csp = ( "default-src 'self'; " "img-src 'self' data: https:; " - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net https://uicdn.toast.com; " "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; " - "script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh; " + "script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://uicdn.toast.com; " "connect-src 'self' ws: wss:; " "frame-ancestors 'none'" ) diff --git a/app/routes/clients.py b/app/routes/clients.py index c8747130..c40dc859 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user import app as app_module @@ -42,7 +42,22 @@ def list_clients(): @login_required def create_client(): """Create a new client""" + # Detect AJAX/JSON request + try: + wants_json = ( + request.headers.get('X-Requested-With') == 'XMLHttpRequest' + or request.is_json + or request.accept_mimetypes['application/json'] >= request.accept_mimetypes['text/html'] + ) + except Exception: + wants_json = False + if not current_user.is_admin: + if wants_json: + return jsonify({ + 'error': 'forbidden', + 'message': _('Only administrators can create clients') + }), 403 flash(_('Only administrators can create clients'), 'error') return redirect(url_for('clients.list_clients')) @@ -66,6 +81,8 @@ def create_client(): # Validate required fields if not name: + if wants_json: + return jsonify({'error': 'validation_error', 'messages': ['Client name is required']}), 400 flash('Client name is required', 'error') try: current_app.logger.warning("Validation failed: missing client name") @@ -75,6 +92,8 @@ def create_client(): # Check if client name already exists if Client.query.filter_by(name=name).first(): + if wants_json: + return jsonify({'error': 'validation_error', 'messages': ['A client with this name already exists']}), 400 flash('A client with this name already exists', 'error') try: current_app.logger.warning("Validation failed: duplicate client name '%s'", name) @@ -86,6 +105,8 @@ def create_client(): try: default_hourly_rate = Decimal(default_hourly_rate) if default_hourly_rate else None except ValueError: + if wants_json: + return jsonify({'error': 'validation_error', 'messages': ['Invalid hourly rate format']}), 400 flash('Invalid hourly rate format', 'error') try: current_app.logger.warning("Validation failed: invalid hourly rate '%s'", default_hourly_rate) @@ -106,6 +127,8 @@ def create_client(): db.session.add(client) if not safe_commit('create_client', {'name': name}): + if wants_json: + return jsonify({'error': 'db_error', 'message': 'Could not create client due to a database error.'}), 500 flash('Could not create client due to a database error. Please check server logs.', 'error') return render_template('clients/create.html') @@ -113,6 +136,13 @@ def create_client(): app_module.log_event("client.created", user_id=current_user.id, client_id=client.id) app_module.track_event(current_user.id, "client.created", {"client_id": client.id}) + if wants_json: + return jsonify({ + 'id': client.id, + 'name': client.name, + 'default_hourly_rate': float(client.default_hourly_rate) if client.default_hourly_rate is not None else None + }), 201 + flash(f'Client "{name}" created successfully', 'success') return redirect(url_for('clients.view_client', client_id=client.id)) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index d3f4c252..afd948af 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -214,6 +214,7 @@ def edit_task(task_id): # Preload context for potential validation errors projects = Project.query.filter_by(status='active').order_by(Project.name).all() users = User.query.order_by(User.username).all() + project_id = request.form.get('project_id', type=int) name = request.form.get('name', '').strip() description = request.form.get('description', '').strip() priority = request.form.get('priority', 'medium') @@ -225,6 +226,15 @@ def edit_task(task_id): if not name: flash('Task name is required', 'error') return render_template('tasks/edit.html', task=task, projects=projects, users=users) + + # Validate project selection + if not project_id: + flash('Project is required', 'error') + return render_template('tasks/edit.html', task=task, projects=projects, users=users) + new_project = Project.query.filter_by(id=project_id, status='active').first() + if not new_project: + flash('Selected project does not exist or is inactive', 'error') + return render_template('tasks/edit.html', task=task, projects=projects, users=users) # Parse estimated hours try: @@ -243,6 +253,19 @@ def edit_task(task_id): return render_template('tasks/edit.html', task=task, projects=projects, users=users) # Update task + # Handle project change first so any early returns (status flows) still persist it + if project_id != task.project_id: + old_project_id = task.project_id + task.project_id = project_id + # Keep related time entries consistent with the task's project + try: + for entry in task.time_entries.all(): + entry.project_id = project_id + db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='project_change', details=f"Project changed from {old_project_id} to {project_id}")) + except Exception: + # If anything goes wrong here, fall back to just changing the task + pass + task.name = name task.description = description task.priority = priority diff --git a/app/static/enhanced-ui.css b/app/static/enhanced-ui.css index ac77abb5..16e9f749 100644 --- a/app/static/enhanced-ui.css +++ b/app/static/enhanced-ui.css @@ -639,3 +639,87 @@ top: 0; } +/* ============================================ + Markdown content styling (fallback to match app) + Applies when Tailwind Typography is not available + ============================================ */ +.prose, .prose-sm { + color: #2D3748; + line-height: 1.7; +} +.dark .prose, .dark .prose-sm { color: #E2E8F0; } +.prose h1, .prose h2, .prose h3, .prose-sm h1, .prose-sm h2, .prose-sm h3 { + color: inherit; + font-weight: 700; + margin: 0.75rem 0 0.5rem; +} +.prose h4, .prose h5, .prose h6, .prose-sm h4, .prose-sm h5, .prose-sm h6 { + color: inherit; + font-weight: 600; + margin: 0.75rem 0 0.5rem; +} +.prose p, .prose-sm p { margin: 0.5rem 0; } +.prose a, .prose-sm a { color: #3B82F6; text-decoration: underline; } +.dark .prose a, .dark .prose-sm a { color: #60A5FA; } +.prose ul, .prose ol, .prose-sm ul, .prose-sm ol { padding-left: 1.25rem; margin: 0.5rem 0; } +.prose li, .prose-sm li { margin: 0.25rem 0; } +.prose code, .prose-sm code { + background: #F7F9FB; + color: #1F2937; + padding: 0.1rem 0.3rem; + border-radius: 4px; +} +.dark .prose code, .dark .prose-sm code { background: #1F2937; color: #E5E7EB; } +.prose pre, .prose-sm pre { + background: #0B1220; + color: #E5E7EB; + padding: 0.75rem 1rem; + border-radius: 8px; + overflow-x: auto; +} +.dark .prose pre, .dark .prose-sm pre { background: #0B1220; } +.prose blockquote, .prose-sm blockquote { + border-left: 4px solid #3B82F6; + padding-left: 0.75rem; + margin: 0.75rem 0; + color: #475569; +} +.dark .prose blockquote, .dark .prose-sm blockquote { color: #94A3B8; } +.prose table, .prose-sm table { width: 100%; border-collapse: collapse; } +.prose table th, .prose table td, .prose-sm table th, .prose-sm table td { + border: 1px solid #E2E8F0; padding: 0.5rem 0.75rem; +} +.dark .prose table th, .dark .prose table td, .dark .prose-sm table th, .dark .prose-sm table td { border-color: #4A5568; } +.prose img, .prose-sm img { max-width: 100%; border-radius: 8px; } + +/* ============================================ + Toast UI Editor – theme bridging to match app + ============================================ */ +.toastui-editor-defaultUI { + background: #FFFFFF; + border: 1px solid #E2E8F0; + border-radius: 8px; +} +.dark .toastui-editor-defaultUI { + background: #2D3748; + border-color: #4A5568; +} +.toastui-editor-defaultUI .toastui-editor-toolbar { + background: transparent; + border-bottom-color: #E2E8F0; +} +.dark .toastui-editor-defaultUI .toastui-editor-toolbar { border-bottom-color: #4A5568; } +.toastui-editor-defaultUI .ProseMirror, +.toastui-editor-contents { + color: #2D3748; +} +.dark .toastui-editor-defaultUI .ProseMirror, +.dark .toastui-editor-contents { + color: #E2E8F0; +} +.toastui-editor-contents a { color: #3B82F6; } +.dark .toastui-editor-contents a { color: #60A5FA; } +.toastui-editor-contents pre { background: #0B1220; color: #E5E7EB; border-radius: 8px; } +.toastui-editor-contents code { background: #F7F9FB; color: #1F2937; padding: 0.1rem 0.3rem; border-radius: 4px; } +.dark .toastui-editor-contents code { background: #1F2937; color: #E5E7EB; } + diff --git a/app/templates/clients/view.html b/app/templates/clients/view.html index 677449de..6f70e298 100644 --- a/app/templates/clients/view.html +++ b/app/templates/clients/view.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "components/ui.html" import confirm_dialog %} {% block content %}
@@ -7,7 +8,16 @@

{{ client.name }}

Client details and associated projects.

{% if current_user.is_admin %} - Edit Client +
+ Edit Client + + +
{% endif %} @@ -70,4 +80,14 @@

Projects

+{% if current_user.is_admin %} +{{ confirm_dialog( + 'confirmDeleteClient-' ~ client.id, + 'Delete Client', + 'Are you sure you want to delete this client? This action cannot be undone.', + 'Delete', + 'Cancel', + 'danger' +) }} +{% endif %} {% endblock %} diff --git a/app/templates/projects/_kanban_tailwind.html b/app/templates/projects/_kanban_tailwind.html index 900798ea..01eb11fe 100644 --- a/app/templates/projects/_kanban_tailwind.html +++ b/app/templates/projects/_kanban_tailwind.html @@ -20,7 +20,7 @@

+
{% set column_tasks = tasks|selectattr('status', 'equalto', col.key)|list %} {% if column_tasks %} {% for task in column_tasks %} diff --git a/app/templates/projects/view.html b/app/templates/projects/view.html index 332cfda2..42ca9d62 100644 --- a/app/templates/projects/view.html +++ b/app/templates/projects/view.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "components/ui.html" import confirm_dialog %} {% block content %}
@@ -7,7 +8,16 @@

{{ project.name }}

{{ project.client.name }}

{% if current_user.is_admin %} - Edit Project +
+ Edit Project + + +
{% endif %}
@@ -19,7 +29,7 @@

Details

Description

-

{{ project.description or 'No description provided.' }}

+
{{ (project.description or 'No description provided.') | markdown | safe }}

Status

@@ -110,6 +120,16 @@

{{ _('Tasks for this project') }}

+{% if current_user.is_admin %} +{{ confirm_dialog( + 'confirmDeleteProject-' ~ project.id, + 'Delete Project', + 'Are you sure you want to delete this project? This action cannot be undone.', + 'Delete', + 'Cancel', + 'danger' +) }} +{% endif %} {% endblock %} {% block scripts_extra %}{% endblock %} diff --git a/app/templates/tasks/edit.html b/app/templates/tasks/edit.html index d6cfab09..92526542 100644 --- a/app/templates/tasks/edit.html +++ b/app/templates/tasks/edit.html @@ -16,8 +16,8 @@

{{ _('Edit Task') }}

{{ _('Back to Task') }} -
-
+
+
@@ -33,8 +33,8 @@

{{ _('Edit Task') }}

-
-
+
+
{{ _('Task Information') }}
@@ -58,7 +58,7 @@
{{ _('Description') }}
- +

{{ _('Optional: Add context, requirements, or specific instructions for the task') }}

@@ -149,7 +149,7 @@
+
diff --git a/app/templates/tasks/list.html b/app/templates/tasks/list.html index 3d4417c1..c3e4a4ce 100644 --- a/app/templates/tasks/list.html +++ b/app/templates/tasks/list.html @@ -28,7 +28,13 @@
-

Filter Tasks

+
+

Filter Tasks

+ +
+
@@ -81,6 +87,7 @@

Filter Tasks

+
@@ -170,4 +177,41 @@

{% endblock %} -{% block content_after %}{% endblock %} \ No newline at end of file +{% block scripts_extra %} + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/tasks/view.html b/app/templates/tasks/view.html index fcf972b5..78dcdeca 100644 --- a/app/templates/tasks/view.html +++ b/app/templates/tasks/view.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "components/ui.html" import confirm_dialog %} {% block content %}
@@ -7,7 +8,16 @@

{{ task.name }}

Task details and history.

{% if current_user.is_admin or task.created_by == current_user.id %} - Edit Task +
+ Edit Task + + +
{% endif %}
@@ -17,7 +27,7 @@

{{ task.name }}

{% if task.description %}

Description

-

{{ task.description }}

+
{{ task.description | markdown | safe }}
{% endif %} @@ -83,4 +93,14 @@

+{% if current_user.is_admin or task.created_by == current_user.id %} +{{ confirm_dialog( + 'confirmDeleteTask-' ~ task.id, + 'Delete Task', + 'Are you sure you want to delete this task? This action cannot be undone.', + 'Delete', + 'Cancel', + 'danger' +) }} +{% endif %} {% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index 39ad28e6..6f4823cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,7 @@ services: # 5. Check the token hasn't expired (increase WTF_CSRF_TIME_LIMIT if needed) # 6. If accessing via IP (not localhost): WTF_CSRF_SSL_STRICT=false # For details: docs/CSRF_CONFIGURATION.md and docs/CSRF_IP_ACCESS_GUIDE.md + - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this} # Disable strict Referer check by default to avoid privacy/port issues - WTF_CSRF_SSL_STRICT=${WTF_CSRF_SSL_STRICT:-true} - WTF_CSRF_ENABLED=${WTF_CSRF_ENABLED:-true} @@ -83,7 +84,7 @@ services: ports: [] volumes: - app_data:/data - - ./logs:/app/logs + - app_logs:/app/logs depends_on: db: condition: service_healthy @@ -176,6 +177,8 @@ services: volumes: app_data: driver: local + app_logs: + driver: local db_data: driver: local prometheus_data: diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b3bb359c..b6d387be 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -25,10 +25,16 @@ The fastest way to get TimeTracker running: git clone https://github.com/drytrix/TimeTracker.git cd TimeTracker -# 2. Start TimeTracker +# 2. Set a strong SECRET_KEY (required for sessions & CSRF) +# Linux/macOS: +export SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))") +# Windows PowerShell: +$env:SECRET_KEY = python -c "import secrets; print(secrets.token_hex(32))" + +# 3. Start TimeTracker docker-compose up -d -# 3. Access the application +# 4. Access the application # Open your browser to: https://localhost # (Self‑signed certificate; your browser will show a warning the first time.) @@ -40,6 +46,8 @@ docker-compose up -d **That's it!** TimeTracker is now running with PostgreSQL. +> Important: The default `docker-compose.yml` expects `SECRET_KEY` to be set. You can also edit the file and replace `SECRET_KEY=your-secret-key-here` with a securely generated value. Never use weak or guessable keys. + ### Option 2: Quick Test (SQLite) Want to try it without setting up a database? diff --git a/docs/TASK_MANAGEMENT_README.md b/docs/TASK_MANAGEMENT_README.md index 46b361c1..98a33dd2 100644 --- a/docs/TASK_MANAGEMENT_README.md +++ b/docs/TASK_MANAGEMENT_README.md @@ -8,6 +8,14 @@ The Task Management feature allows you to break down projects into manageable ta ### Core Functionality - **Task Creation**: Create tasks within projects with names, descriptions, and priorities +### Markdown Support + +Task and project descriptions support Markdown formatting with a rich editor: + +- Editing: Task create/edit and Project create/edit pages include a Markdown editor with dark-mode and image uploads +- Rendering: View pages render Markdown via the `markdown` Jinja filter (`app/utils/template_filters.py`), sanitized with Bleach +- Supported: headings, emphasis, lists, links, code blocks, tables, images + - **Status Tracking**: Monitor task progress through different states (To Do, In Progress, Review, Done) - **Priority Management**: Set and track task priorities (Low, Medium, High, Urgent) - **Time Estimation**: Estimate and track actual time for tasks @@ -72,6 +80,21 @@ Task Management migration check completed - **Project Tasks**: Tasks within a specific project - **Overdue Tasks**: Admin view of all overdue tasks +### Filters UI +- The **All Tasks** page includes a filter panel that is **collapsible**. +- Use the chevron button near "Filter Tasks" to show/hide the filters. +- Your preference is **remembered** using `localStorage` so the panel stays collapsed or expanded across visits. + +### Inline Client Creation (Quality of Life) + +When creating a new project on `Projects → Create`, you can now create a new client without losing your filled-in project data: + +- Click "Create new client" under the Client dropdown to open a modal +- Fill in the minimal client details and submit +- The newly created client is selected automatically and the default hourly rate is prefilled if available + +This flow uses an AJAX request to `POST /clients/create` and updates the client select dynamically, preserving the current project form state. + ## Database Schema ### Tasks Table diff --git a/logs/app.jsonl b/logs/app.jsonl index 1d1fc585..a988e865 100644 --- a/logs/app.jsonl +++ b/logs/app.jsonl @@ -23,3 +23,12 @@ {"asctime": "2025-10-20 20:40:42,782", "levelname": "INFO", "name": "timetracker", "message": "project.created", "request_id": "86e686d1-750c-4599-8009-1ae8284b9576", "event": "project.created", "user_id": 1, "project_id": 8, "project_name": "fezfjsvvjkldfjl", "has_client": true} {"asctime": "2025-10-20 20:43:44,701", "levelname": "INFO", "name": "timetracker", "message": "setup.completed", "request_id": "17a2f9be-8851-4caf-9129-43643cda15ce", "event": "setup.completed", "telemetry_enabled": true} {"asctime": "2025-10-20 20:43:50,049", "levelname": "INFO", "name": "timetracker", "message": "auth.login", "request_id": "b2e3a7e8-828a-4aec-8efa-f27d5728f164", "event": "auth.login", "user_id": 1, "auth_method": "local"} +{"asctime": "2025-10-21 13:09:39,323", "levelname": "INFO", "name": "timetracker", "message": "setup.completed", "request_id": "0e1915f5-507f-4c48-a46c-58e427cae277", "event": "setup.completed", "telemetry_enabled": true} +{"asctime": "2025-10-21 13:09:42,053", "levelname": "INFO", "name": "timetracker", "message": "auth.login", "request_id": "a0ca4577-e58d-4e83-860f-4dc2631ad1a5", "event": "auth.login", "user_id": 1, "auth_method": "local"} +{"asctime": "2025-10-21 13:17:58,706", "levelname": "INFO", "name": "timetracker", "message": "setup.completed", "request_id": "bf613ab0-889d-412c-9146-4ec376496ee1", "event": "setup.completed", "telemetry_enabled": true} +{"asctime": "2025-10-21 13:18:01,044", "levelname": "INFO", "name": "timetracker", "message": "auth.login", "request_id": "66357edf-7158-4fa3-97c7-258e36e04335", "event": "auth.login", "user_id": 1, "auth_method": "local"} +{"asctime": "2025-10-21 13:18:33,803", "levelname": "INFO", "name": "timetracker", "message": "task.created", "request_id": "011cfd1d-43c8-4bf1-93c0-225c062180cb", "event": "task.created", "user_id": 1, "task_id": 1, "project_id": 1, "priority": "medium"} +{"asctime": "2025-10-21 16:02:08,457", "levelname": "INFO", "name": "timetracker", "message": "setup.completed", "request_id": "48d537a1-0ed1-452e-bbdc-030e83af940c", "event": "setup.completed", "telemetry_enabled": true} +{"asctime": "2025-10-21 16:02:11,272", "levelname": "INFO", "name": "timetracker", "message": "auth.login", "request_id": "9f445ef0-b6e6-4631-8bc5-c40c5689a501", "event": "auth.login", "user_id": 1, "auth_method": "local"} +{"asctime": "2025-10-21 16:02:32,456", "levelname": "INFO", "name": "timetracker", "message": "report.viewed", "request_id": "d1de7948-78b1-4b74-b534-e342fe7f7830", "event": "report.viewed", "user_id": 1, "report_type": "summary"} +{"asctime": "2025-10-21 16:02:59,857", "levelname": "INFO", "name": "timetracker", "message": "task.updated", "request_id": "090070f8-6a79-4eaa-bb07-805cdf525ce7", "event": "task.updated", "user_id": 1, "task_id": 1, "project_id": 1} diff --git a/setup.py b/setup.py index 9cb2c6ca..e30945eb 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='timetracker', - version='3.2.0', + version='3.2.1', packages=find_packages(), include_package_data=True, install_requires=[ diff --git a/templates/clients/create.html b/templates/clients/create.html index ad28ec7f..6409a563 100644 --- a/templates/clients/create.html +++ b/templates/clients/create.html @@ -30,8 +30,14 @@

{{ _('Create Client') }}

- - +
+ + {{ _('Supports Markdown') }} +
+
+ +
+
@@ -89,3 +95,41 @@

{{ _('Help') }}

{% endblock %} + +{% block extra_css %} + + +{% endblock %} + + + diff --git a/templates/clients/view.html b/templates/clients/view.html index adbb9ff7..489d5447 100644 --- a/templates/clients/view.html +++ b/templates/clients/view.html @@ -101,7 +101,7 @@
{% if client.description %}
{{ _('Description') }}
-
{{ client.description }}
+
{{ client.description | markdown | safe }}
{% endif %} {% if client.contact_person %} diff --git a/templates/projects/create.html b/templates/projects/create.html index dc2bea02..f45360c9 100644 --- a/templates/projects/create.html +++ b/templates/projects/create.html @@ -31,13 +31,21 @@

{{ _('Create Project') }}

{% endfor %} -

{{ _('Create new client') }}

+

+ +

- - +
+ + {{ _('Supports Markdown') }} +
+
+ +
+

{{ _('Optional: Add context, objectives, or specific requirements for the project') }}

@@ -194,6 +202,12 @@

{{ _('Project Creation Tips') }}

} +{% block extra_css %} + + +{% endblock %} + + + + + {% endblock %} diff --git a/templates/projects/edit.html b/templates/projects/edit.html index ea6c42a0..f55a4ae7 100644 --- a/templates/projects/edit.html +++ b/templates/projects/edit.html @@ -35,8 +35,14 @@

{{ _('Edit Project') }}

- - +
+ + {{ _('Supports Markdown') }} +
+
+ +
+
@@ -78,10 +84,17 @@

{{ _('Edit Project') }}

+{% block extra_css %} + + +{% endblock %} + + {% endblock %} diff --git a/templates/projects/view.html b/templates/projects/view.html index f584a250..59ebd65d 100644 --- a/templates/projects/view.html +++ b/templates/projects/view.html @@ -112,7 +112,7 @@
{% if project.description %}
{{ _('Description') }}
-
{{ project.description }}
+
{{ project.description | markdown | safe }}
{% endif %}
diff --git a/tests/test_delete_actions.py b/tests/test_delete_actions.py new file mode 100644 index 00000000..f12457ae --- /dev/null +++ b/tests/test_delete_actions.py @@ -0,0 +1,66 @@ +import pytest + + +@pytest.mark.integration +@pytest.mark.routes +def test_task_view_shows_delete_button(authenticated_client, task, app): + with app.app_context(): + resp = authenticated_client.get(f"/tasks/{task.id}") + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + assert 'Delete Task' in html + + +@pytest.mark.integration +@pytest.mark.routes +def test_client_view_shows_delete_button(admin_authenticated_client, test_client, app): + with app.app_context(): + resp = admin_authenticated_client.get(f"/clients/{test_client.id}") + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + assert 'Delete Client' in html + + +@pytest.mark.integration +@pytest.mark.routes +def test_project_view_shows_delete_button(admin_authenticated_client, project, app): + with app.app_context(): + resp = admin_authenticated_client.get(f"/projects/{project.id}") + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + assert 'Delete Project' in html + + +@pytest.mark.integration +@pytest.mark.routes +def test_delete_task_flow(authenticated_client, task, app): + with app.app_context(): + # Delete + resp = authenticated_client.post(f"/tasks/{task.id}/delete", follow_redirects=False) + assert resp.status_code in [302, 303] + # Verify gone + resp2 = authenticated_client.get(f"/tasks/{task.id}") + assert resp2.status_code in [302, 404] + + +@pytest.mark.integration +@pytest.mark.routes +def test_delete_client_flow_blocked_when_projects_exist(admin_authenticated_client, test_client, project, app): + with app.app_context(): + # Attempt delete should fail due to existing project + resp = admin_authenticated_client.post(f"/clients/{test_client.id}/delete", follow_redirects=False) + # Should redirect back with error flash + assert resp.status_code in [302, 303] + + +@pytest.mark.integration +@pytest.mark.routes +def test_delete_project_flow(admin_authenticated_client, project, app): + with app.app_context(): + resp = admin_authenticated_client.post(f"/projects/{project.id}/delete", follow_redirects=False) + assert resp.status_code in [302, 303] + # Verify gone + resp2 = admin_authenticated_client.get(f"/projects/{project.id}") + assert resp2.status_code in [302, 404] + + diff --git a/tests/test_new_features.py b/tests/test_new_features.py index 9829e650..21eb0855 100644 --- a/tests/test_new_features.py +++ b/tests/test_new_features.py @@ -32,3 +32,22 @@ def test_saved_filter_model_roundtrip(app): assert as_dict['name'] == 'My Filter' assert as_dict['scope'] == 'time' + +@pytest.mark.api +@pytest.mark.integration +def test_inline_client_creation_json_flow(admin_authenticated_client): + """Creating a client via AJAX JSON should return 201 and client payload.""" + resp = admin_authenticated_client.post( + '/clients/create', + data={ + 'name': 'Inline Modal Client', + 'default_hourly_rate': '123.45' + }, + headers={'X-Requested-With': 'XMLHttpRequest'} + ) + assert resp.status_code in (201, 400, 403) + if resp.status_code == 201: + data = resp.get_json() + assert data['name'] == 'Inline Modal Client' + assert data['id'] > 0 + diff --git a/tests/test_routes.py b/tests/test_routes.py index c21a4a89..31479369 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -121,6 +121,16 @@ def test_projects_list_page(authenticated_client): assert response.status_code == 200 +@pytest.mark.integration +@pytest.mark.routes +def test_projects_create_page_contains_client_modal_trigger(admin_authenticated_client): + """Projects create page should contain inline client creation trigger.""" + response = admin_authenticated_client.get('/projects/create') + assert response.status_code == 200 + html = response.get_data(as_text=True) + assert 'id="openCreateClientModal"' in html + + @pytest.mark.integration @pytest.mark.routes @pytest.mark.xfail(reason="Endpoint /projects/new may not exist or uses different URL") diff --git a/tests/test_task_edit_project.py b/tests/test_task_edit_project.py new file mode 100644 index 00000000..431426e0 --- /dev/null +++ b/tests/test_task_edit_project.py @@ -0,0 +1,63 @@ +import pytest +from datetime import datetime, timedelta + +from app import db +from app.models import Project, Task, TimeEntry + + +@pytest.mark.smoke +@pytest.mark.routes +def test_edit_task_changes_project_and_updates_time_entries(authenticated_client, app, user, test_client): + with app.app_context(): + # Create two active projects under the same client + project1 = Project(name='Project One', client_id=test_client.id, description='P1') + project1.status = 'active' + project2 = Project(name='Project Two', client_id=test_client.id, description='P2') + project2.status = 'active' + db.session.add_all([project1, project2]) + db.session.commit() + + # Create a task on project1 + task = Task(project_id=project1.id, name='Move Me', created_by=user.id) + db.session.add(task) + db.session.commit() + + # Create a time entry associated with this task and project1 + start_time = datetime.utcnow() - timedelta(hours=2) + end_time = datetime.utcnow() - timedelta(hours=1) + entry = TimeEntry( + user_id=user.id, + project_id=project1.id, + task_id=task.id, + start_time=start_time, + end_time=end_time, + notes='Work on task before moving' + ) + db.session.add(entry) + db.session.commit() + + # Submit edit form to change the project to project2 + resp = authenticated_client.post( + f'/tasks/{task.id}/edit', + data={ + 'project_id': project2.id, + 'name': task.name, + 'description': '', + 'priority': 'medium', + 'estimated_hours': '', + 'due_date': '', + 'assigned_to': '' + }, + follow_redirects=False + ) + + # Expect redirect to task view + assert resp.status_code in (302, 303) + + # Refresh objects and verify project change persisted + db.session.refresh(task) + db.session.refresh(entry) + assert task.project_id == project2.id + assert entry.project_id == project2.id + + diff --git a/tests/test_tasks_filters_ui.py b/tests/test_tasks_filters_ui.py new file mode 100644 index 00000000..bb9773aa --- /dev/null +++ b/tests/test_tasks_filters_ui.py @@ -0,0 +1,49 @@ +import pytest + + +@pytest.mark.smoke +def test_task_view_renders_markdown(app, client, task, authenticated_client): + # Arrange: give the task a markdown description + from app import db + task.description = "# Heading\n\n**Bold** and _italic_." + db.session.commit() + + # Act + resp = authenticated_client.get(f"/tasks/{task.id}") + + # Assert: the rendered HTML should include tags produced by markdown filter + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + assert '

' in html or '

' in html + assert '' in html or '' in html + + +@pytest.mark.smoke +def test_project_view_renders_markdown(app, client, project, admin_authenticated_client): + from app import db + project.description = "Intro with a list:\n\n- item one\n- item two" + db.session.commit() + + resp = admin_authenticated_client.get(f"/projects/{project.id}") + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + # Look for list markup from markdown + assert '
    ' in html and '
  • ' in html + +import pytest + + +@pytest.mark.unit +@pytest.mark.routes +@pytest.mark.smoke +def test_tasks_filters_collapsible_ui(authenticated_client): + resp = authenticated_client.get('/tasks') + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + assert 'id="toggleFilters"' in html + assert 'id="filterBody"' in html + assert 'id="filterToggleIcon"' in html + # Ensure localStorage key is referenced (persisted visibility) + assert 'taskListFiltersVisible' in html + +