- Added customer search functionality in the solution management editor, allowing users to select a customer. - Updated the API endpoint for incoming call logging to use the local recipient extension. - Implemented a new case number navigation feature in the global search. - Modified case creation logic to include relations and adjust module visibility. - Added tests for new features including customer selection in solutions and call logging URL changes.
127 lines
5.9 KiB
Python
127 lines
5.9 KiB
Python
from datetime import datetime, time, timezone
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from app.modules.planner.backend import router as planner
|
|
|
|
|
|
def test_planner_migration_has_independent_versioned_allocations_and_capacity_sources():
|
|
sql = Path("migrations/240_planner.sql").read_text(encoding="utf-8")
|
|
for table in (
|
|
"planner_allocations", "planner_allocation_history", "planner_work_schedules",
|
|
"planner_absences", "planner_external_busy", "planner_integrations", "planner_settings",
|
|
):
|
|
assert f"CREATE TABLE IF NOT EXISTS {table}" in sql
|
|
assert "version INTEGER NOT NULL DEFAULT 1" in sql
|
|
assert "ALTER TABLE sag_sager" in sql
|
|
assert "estimated_minutes" in sql
|
|
assert "planner.edit_team" in sql
|
|
|
|
|
|
def test_allocation_input_rejects_zero_or_negative_duration():
|
|
with pytest.raises(Exception):
|
|
planner.AllocationInput(
|
|
sag_id=1, user_id=2,
|
|
starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 10),
|
|
)
|
|
|
|
|
|
def test_capacity_uses_work_time_less_break_absence_and_external_busy(monkeypatch):
|
|
def fake_single(query, _params=None):
|
|
if "planner_settings" in query:
|
|
return {"warning_percent": 80, "full_percent": 100}
|
|
return None
|
|
|
|
def fake_query(query, _params=None):
|
|
if "planner_work_schedules" in query:
|
|
return [{"user_id": 7, "weekday": 1, "start_time": time(8), "end_time": time(16), "break_minutes": 30}]
|
|
if "planner_absences" in query:
|
|
return [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 13, tzinfo=planner.TZ)}]
|
|
return []
|
|
|
|
monkeypatch.setattr(planner, "execute_query_single", fake_single)
|
|
monkeypatch.setattr(planner, "execute_query", fake_query)
|
|
monkeypatch.setattr(planner.busy_time_provider, "get_busy", lambda *_: [{
|
|
"user_id": 7, "starts_at": datetime(2026, 9, 8, 14, tzinfo=planner.TZ),
|
|
"ends_at": datetime(2026, 9, 8, 15, tzinfo=planner.TZ), "provider": "outlook",
|
|
}])
|
|
allocations = [{"user_id": 7, "starts_at": datetime(2026, 9, 8, 8, tzinfo=planner.TZ), "ends_at": datetime(2026, 9, 8, 12, tzinfo=planner.TZ)}]
|
|
result = planner._capacity(
|
|
[7], datetime(2026, 9, 8, tzinfo=planner.TZ),
|
|
datetime(2026, 9, 9, tzinfo=planner.TZ), allocations,
|
|
)[0]
|
|
assert result["available_minutes"] == 330 # 8h - 30m break - 2h unavailable
|
|
assert result["allocated_minutes"] == 240
|
|
assert result["state"] == "green"
|
|
assert result["label"] == "4 af 5.5 timer planlagt"
|
|
assert result["registered_minutes"] == 0
|
|
assert result["registered_label"] == "0 timer registreret"
|
|
|
|
|
|
def test_own_and_team_edit_permissions_are_separate(monkeypatch):
|
|
own = {"id": 4, "permissions": ["planner.edit_own"]}
|
|
assert planner._can_edit(own, 4)
|
|
assert not planner._can_edit(own, 5)
|
|
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"allowed": 1})
|
|
assert planner._can_edit({"id": 4, "permissions": ["planner.edit_team"]}, 5)
|
|
|
|
|
|
def test_overlapping_allocations_each_consume_capacity():
|
|
start = datetime(2026, 9, 8, 8, tzinfo=planner.TZ)
|
|
end = datetime(2026, 9, 8, 12, tzinfo=planner.TZ)
|
|
assert planner._sum_minutes([(start, end), (start, end)], start, end) == 480
|
|
|
|
|
|
def test_planner_ui_exposes_views_filters_capacity_used_time_and_undo():
|
|
html = Path("app/modules/planner/templates/index.html").read_text(encoding="utf-8")
|
|
js = Path("static/js/planner.js").read_text(encoding="utf-8")
|
|
for text in ("Min plan", "Team", "Dag", "Uge", "Måned", "Ikke planlagt", "Delvist planlagt", "Mine sager", "Haster"):
|
|
assert text in html
|
|
for feature in ("application/x-case", "application/x-allocation", "confirm_conflicts", "undoMutation", "time-entries", "used_minutes", "estimated_minutes", "dialogLocked", "hourMarks", "blockStyle", "overlapLayout"):
|
|
assert feature in js
|
|
assert "Fastlås tidspunkt" in html
|
|
assert "planner.estimate.update" in Path("app/modules/planner/backend/router.py").read_text()
|
|
assert "registered-time" in html
|
|
assert "registeredHtml" in js
|
|
assert "time_entries" in js
|
|
assert "Ekstern travl tid" in js
|
|
assert "green" in html and "yellow" in html and "red" in html and "gray" in html
|
|
|
|
|
|
def test_case_planner_exposes_case_specific_allocations_endpoint():
|
|
source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8")
|
|
|
|
assert '@router.get("/cases/{sag_id}/allocations")' in source
|
|
assert "def case_allocations(" in source
|
|
assert "WHERE a.sag_id=%s AND a.deleted_at IS NULL" in source
|
|
assert '"remaining_minutes"' in source
|
|
assert '"deferred"' in source
|
|
assert '"subcases"' in source
|
|
assert "allocation_summary.planned_minutes" in source
|
|
assert "COALESCE(allocation_summary.items, '[]'::json) AS allocations" in source
|
|
|
|
|
|
def test_closed_case_is_checked_before_allocation(monkeypatch):
|
|
monkeypatch.setattr(planner, "_can_edit", lambda *_: True)
|
|
monkeypatch.setattr(planner, "execute_query_single", lambda *_: {"id": 9, "status": "afsluttet", "deleted_at": None})
|
|
payload = planner.AllocationInput(
|
|
sag_id=9, user_id=2, starts_at=datetime(2026, 9, 8, 10), ends_at=datetime(2026, 9, 8, 11)
|
|
)
|
|
with pytest.raises(HTTPException) as exc:
|
|
planner.create_allocation(payload, {"id": 2, "permissions": ["planner.edit_own"]})
|
|
assert exc.value.status_code == 409
|
|
|
|
|
|
def test_active_case_list_only_hides_closed_cases_that_already_have_time():
|
|
source = Path("app/modules/planner/backend/router.py").read_text(encoding="utf-8")
|
|
cases_query = source.split('@router.get("/cases")', 1)[1].split('@router.get("/cases/{sag_id}/time-entries")', 1)[0]
|
|
assert "NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)" in cases_query
|
|
assert "AS is_closed" in cases_query
|