Merge develop into master
All checks were successful
Deploy production / deploy (push) Successful in 9m21s

This commit is contained in:
lizerk 2026-09-06 12:02:25 +03:00
commit 587bccd0e1
6 changed files with 364 additions and 118 deletions

View File

@ -361,7 +361,6 @@ def filter_events_for_user(
return queryset.filter(
Q(calendar_id__in=calendar_ids)
| Q(id__in=participant_event_ids)
| Q(calendar__binding__type=CalendarBinding.BindingType.DEPARTMENT)
| Q(
calendar__binding__type=CalendarBinding.BindingType.DEFAULT,
calendar__binding__access_scope=CalendarBinding.AccessScope.ALL,

View File

@ -605,6 +605,60 @@ class TestRecurringEvents:
'Admin event',
'Other event',
}
def test_admin_accessible_scope_excludes_unrelated_department_occurrences(
self, api_client, admin_user, user1
):
"""Календарь отдела не становится доступным только из-за его типа."""
department_calendar = Calendar.objects.create(
name='Sales',
slug='department-sales',
)
CalendarBinding.objects.create(
calendar=department_calendar,
type=CalendarBinding.BindingType.DEPARTMENT,
)
rule = Rule.objects.create(
name='Department daily',
description='Department recurrence',
frequency='DAILY',
params='',
)
start = timezone.now().replace(microsecond=0, second=0)
range_end = start + timedelta(days=2)
Event.objects.create(
calendar=department_calendar,
title='Sales recurring event',
start=start,
end=start + timedelta(hours=1),
creator=user1,
rule=rule,
end_recurring_period=range_end,
)
api_client.force_authenticate(user=admin_user)
accessible_occurrences = api_client.get(
'/api/v1/schedule/events/occurrences/',
{
'start': start.isoformat(),
'end': range_end.isoformat(),
},
)
assert accessible_occurrences.status_code == status.HTTP_200_OK
assert accessible_occurrences.data == []
all_occurrences = api_client.get(
'/api/v1/schedule/events/occurrences/',
{
'scope': 'all',
'start': start.isoformat(),
'end': range_end.isoformat(),
},
)
assert all_occurrences.status_code == status.HTTP_200_OK
assert {event['title'] for event in all_occurrences.data} == {
'Sales recurring event',
}
def test_create_rule(self, api_client, user1):
"""Создание правила повторения."""

View File

@ -1,14 +1,15 @@
"use client";
import {
Calendar,
CalendarDays,
ChevronRight,
Clock,
Clock3,
Edit2,
FileText,
Kanban,
Link2,
Loader2,
Repeat2,
Trash2,
Users,
} from "lucide-react";
@ -21,6 +22,7 @@ import { useState, useEffect, useCallback } from "react";
import { apiClient } from "@/lib/api";
import { displayUserName } from "@/lib/shared";
import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks/columns";
import { getCalendarEventDisplayRange } from "@/lib/calendar/eventRange";
import type { TaskBoard, TaskCard, TaskPriority, User } from "@/types/api";
type EventRuleData = {
@ -38,6 +40,7 @@ type EventDetails = Record<string, unknown> & {
start?: string | Date | null;
end?: string | Date | null;
calendar?: number | null;
calendar_name?: string;
color_event?: string | null;
can_edit?: boolean;
can_delete?: boolean;
@ -108,6 +111,13 @@ const WEEKDAY_LABELS: Record<number, string> = {
6: "ВС",
};
const PARTICIPANT_ROLE_LABELS: Record<string, string> = {
owner: "Владелец",
editor: "Редактор",
viewer: "Просмотр",
attendee: "Участник",
};
// Нормализация byweekday в массив чисел
function normalizeByweekday(byweekday: unknown): number[] {
if (!byweekday) return [];
@ -204,22 +214,23 @@ export function ViewEventDetailsModal({
if (!isOpen || !event) return null;
const formatDateTime = (dateStr?: string | Date | null) => {
if (!dateStr) return "";
const date = new Date(dateStr);
return format(date, "d MMMM yyyy, HH:mm", { locale: ru });
};
const formatDate = (dateStr?: string | Date | null) => {
if (!dateStr) return "";
const date = new Date(dateStr);
return format(date, "d MMMM yyyy", { locale: ru });
const label = format(date, "d MMMM yyyy", { locale: ru });
return label.charAt(0).toUpperCase() + label.slice(1);
};
const formattedStart = formatDateTime(event.start);
const formattedEnd = formatDateTime(event.end);
const capitalizedStart = formattedStart.charAt(0).toUpperCase() + formattedStart.slice(1);
const capitalizedEnd = formattedEnd.charAt(0).toUpperCase() + formattedEnd.slice(1);
const formatTime = (dateStr?: string | Date | null) => {
if (!dateStr) return "";
return format(new Date(dateStr), "HH:mm", { locale: ru });
};
const displayRange = getCalendarEventDisplayRange(event);
const formattedStartDate = formatDate(event.start);
const formattedEndDate = formatDate(event.end);
const formattedStartTime = formatTime(event.start);
const formattedEndTime = formatTime(event.end);
const canEdit = event.can_edit !== false;
const canDelete = event.can_delete !== false;
const ruleFrequency = typeof event.rule_data?.frequency === "string"
@ -361,132 +372,167 @@ export function ViewEventDetailsModal({
isOpen={isOpen}
onClose={onClose}
title={event.title}
size="sm"
size="md"
closeOnClickOutside
footer={canEdit || canDelete || event.id ? (
<div className="flex flex-wrap gap-2">
{event.id ? (
<button
onClick={() => void openTaskLinkModal()}
className="app-action-secondary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
>
<Link2 size={16} />
Связать с задачей
</button>
) : null}
{canEdit ? (
<button
onClick={onEdit}
className="app-action-primary flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
>
<Edit2 size={16} />
Редактировать
</button>
) : null}
<div className="app-divider flex flex-col-reverse gap-2 border-t pb-4 pt-4 sm:flex-row sm:items-center sm:justify-between sm:pb-6">
{canDelete ? (
<button
type="button"
onClick={onDelete}
className="app-action-danger rounded-lg px-4 py-2.5 text-sm font-medium"
className="app-action-danger inline-flex items-center justify-center gap-2 rounded-xl px-3.5 py-2.5 text-sm font-medium"
title="Удалить событие"
>
<Trash2 size={16} />
<span className="sm:hidden">Удалить</span>
</button>
) : null}
<div className="flex flex-col-reverse gap-2 sm:ml-auto sm:flex-row">
{event.id ? (
<button
type="button"
onClick={() => void openTaskLinkModal()}
className="app-action-secondary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Link2 size={16} />
Связать задачу
</button>
) : null}
{canEdit ? (
<button
type="button"
onClick={onEdit}
className="app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium"
>
<Edit2 size={16} />
Редактировать
</button>
) : null}
</div>
</div>
) : undefined}
>
<div className="space-y-4">
{/* Event meta */}
<div className="flex items-center gap-2">
{event.rule && (
<span className="app-badge app-badge-accent shrink-0 rounded px-1.5 py-0.5 text-xs">
⟲
</span>
)}
<div className="app-text-muted flex items-center gap-1.5 text-xs">
<div
className="h-2.5 w-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: resolveEventColor(event.color_event) }}
/>
<span className="truncate">Календарь #{event.calendar}</span>
</div>
</div>
{/* Time */}
<div className="flex items-start gap-2.5">
<Clock size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="mb-1 text-sm font-medium text-[var(--foreground)]">Время</p>
<p className="app-text-muted text-xs">
<span className="font-medium">Начало:</span> {capitalizedStart}
</p>
<p className="app-text-muted mt-0.5 text-xs">
<span className="font-medium">Конец:</span> {capitalizedEnd}
</p>
<div className="space-y-4 pb-1">
{/* Основная информация */}
<section className="app-surface-muted relative overflow-hidden rounded-2xl border border-[var(--border-subtle)]">
<div
className="absolute inset-y-0 left-0 w-1"
style={{ backgroundColor: resolveEventColor(event.color_event) }}
/>
<div className="space-y-4 p-4 pl-5">
<div className="flex flex-wrap items-center gap-2">
<span className="app-badge app-badge-interactive gap-1.5 px-2.5 py-1 text-xs font-medium">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: resolveEventColor(event.color_event) }}
/>
{event.calendar_name || `Календарь #${event.calendar}`}
</span>
{event.rule ? (
<span className="app-badge app-badge-accent app-badge-interactive gap-1.5 px-2.5 py-1 text-xs font-medium">
<Repeat2 size={13} />
Повторяется
</span>
) : null}
{displayRange?.isMultiDay ? (
<span className="app-badge app-badge-accent app-badge-interactive px-2.5 py-1 text-xs font-medium">
{displayRange.dayCountLabel}
</span>
) : null}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-3">
<div className="app-text-muted flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide">
<CalendarDays size={14} />
Начало
</div>
<p className="mt-2 text-sm font-semibold text-[var(--foreground)]">{formattedStartDate || "Без даты"}</p>
{formattedStartTime ? <p className="app-text-muted mt-0.5 text-xs">{formattedStartTime}</p> : null}
</div>
<div className="rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-3">
<div className="app-text-muted flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wide">
<Clock3 size={14} />
Завершение
</div>
<p className="mt-2 text-sm font-semibold text-[var(--foreground)]">{formattedEndDate || "Не указано"}</p>
{formattedEndTime ? <p className="app-text-muted mt-0.5 text-xs">{formattedEndTime}</p> : null}
</div>
</div>
{displayRange ? (
<p className="app-text-muted flex items-center gap-2 text-xs">
<Clock3 size={14} className="shrink-0" />
<span>{displayRange.timeLabel}</span>
{displayRange.isMultiDay ? <span>· {displayRange.dateLabel}</span> : null}
</p>
) : null}
</div>
</section>
{/* Recurring Info */}
{event.rule && event.rule_data && (
<div className="app-selected space-y-2 rounded-lg p-4">
<div className="app-accent-text flex items-center gap-2 text-sm font-medium">
<Calendar size={16} />
<span>Повторяющееся событие</span>
</div>
<div className="app-accent-text space-y-1 text-sm">
<div>
Частота: <span className="font-medium">{ruleFrequencyLabel}</span>
<section className="rounded-2xl border border-[color:color-mix(in_srgb,var(--accent-primary)_22%,var(--border-subtle))] bg-[color:color-mix(in_srgb,var(--accent-primary)_7%,var(--surface-primary))] p-4">
<div className="flex items-start gap-3">
<span className="app-selected flex h-9 w-9 shrink-0 items-center justify-center rounded-xl">
<Repeat2 size={17} className="app-accent-text" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[var(--foreground)]">Повторение</p>
<p className="app-text-muted mt-0.5 text-xs">{ruleFrequencyLabel}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
{Boolean(ruleParams?.byweekday) && (
<div>
Дни недели: <span className="font-medium">
<span className="app-badge app-badge-interactive px-2.5 py-1">
Дни: <span className="ml-1 font-medium text-[var(--foreground)]">
{normalizeByweekday(ruleParams?.byweekday).map((day: number) => WEEKDAY_LABELS[day]).join(", ")}
</span>
</div>
</span>
)}
{Boolean(ruleParams?.count) && (
<div>
Повторений: <span className="font-medium">{String(ruleParams?.count)}</span>
</div>
<span className="app-badge app-badge-interactive px-2.5 py-1">
Повторений: <span className="ml-1 font-medium text-[var(--foreground)]">{String(ruleParams?.count)}</span>
</span>
)}
{event.end_recurring_period && (
<div>
До: <span className="font-medium">{formatDate(event.end_recurring_period)}</span>
</div>
<span className="app-badge app-badge-interactive px-2.5 py-1">
До: <span className="ml-1 font-medium text-[var(--foreground)]">{formatDate(event.end_recurring_period)}</span>
</span>
)}
</div>
</div>
</section>
)}
{/* Description */}
{event.description && (
<div className="flex items-start gap-2.5">
<FileText size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="mb-1 text-sm font-medium text-[var(--foreground)]">Описание</p>
<p className="app-text-wrap app-text-muted whitespace-pre-wrap text-xs">
<section className="app-surface-muted rounded-2xl border border-[var(--border-subtle)] p-4">
<div className="mb-3 flex items-center gap-2">
<FileText size={16} className="app-text-muted shrink-0" />
<p className="text-sm font-semibold text-[var(--foreground)]">Описание</p>
</div>
<p className="app-text-wrap app-text-muted whitespace-pre-wrap text-sm leading-relaxed">
{event.description}
</p>
</div>
</div>
</section>
)}
{event.id ? (
<div className="flex items-start gap-2.5">
<Link2 size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<section className="app-surface-muted rounded-2xl border border-[var(--border-subtle)] p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<p className="text-sm font-medium text-[var(--foreground)]">
<Link2 size={16} className="app-text-muted shrink-0" />
<p className="text-sm font-semibold text-[var(--foreground)]">
Связанные объекты
</p>
<span className="app-pill-count rounded-full px-2 py-0.5 text-[10px] font-bold">
<span className="app-badge px-2 py-0.5 text-[10px] font-bold">
{linkedTasks.length}
</span>
</div>
<button
type="button"
onClick={() => void openTaskLinkModal()}
className="app-action-ghost shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
className="app-action-secondary shrink-0 rounded-lg px-2.5 py-1.5 text-xs font-medium"
>
Добавить
</button>
@ -561,22 +607,22 @@ export function ViewEventDetailsModal({
</p>
</div>
)}
</div>
</div>
</section>
) : null}
{/* Participants */}
{showParticipants && (
<div className="flex items-start gap-2.5">
<Users size={18} className="app-text-muted mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="mb-1.5 text-sm font-medium text-[var(--foreground)]">
<section className="app-surface-muted rounded-2xl border border-[var(--border-subtle)] p-4">
<div className="mb-3 flex items-center gap-2">
<Users size={16} className="app-text-muted shrink-0" />
<p className="text-sm font-semibold text-[var(--foreground)]">
Участники {participants.length > 0 && `(${participants.length})`}
</p>
</p>
</div>
{loadingParticipants ? (
<p className="app-text-muted text-xs">Загрузка...</p>
) : participants.length > 0 ? (
<div className="space-y-1.5">
<div className="grid gap-2 sm:grid-cols-2">
{participants.map((participant) => (
<div
key={participant.id}
@ -592,7 +638,7 @@ export function ViewEventDetailsModal({
{participant.user_name}
</div>
<div className="app-text-muted">
{participant.distinction || "attendee"}
{PARTICIPANT_ROLE_LABELS[participant.distinction || "attendee"] || participant.distinction}
</div>
</div>
</div>
@ -601,8 +647,7 @@ export function ViewEventDetailsModal({
) : (
<p className="app-text-muted text-xs">Нет участников</p>
)}
</div>
</div>
</section>
)}
</div>

View File

@ -2,7 +2,7 @@
import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { Bell, ChevronDown, ChevronLeft, ChevronRight, Pencil, ShieldCheck, Trash2, Users } from "lucide-react";
import { Bell, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Clock3, Pencil, ShieldCheck, Trash2, Users } from "lucide-react";
import { apiClient } from "@/lib/api";
import { useCalendar } from "@/contexts/CalendarContext";
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
@ -19,6 +19,7 @@ import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-col
import {
calendarEventOverlapsRange,
formatLocalDateKey,
getCalendarEventDisplayRange,
getCalendarEventDateKeys,
} from "@/lib/calendar/eventRange";
import {
@ -109,6 +110,11 @@ export const CalendarCard = memo(function CalendarCard({
[calendars, selectedCalendar],
);
const calendarNamesById = useMemo(
() => new Map(calendars.map((calendar) => [calendar.id, calendar.name])),
[calendars],
);
const participantsModalAvailable = canOpenParticipantsModal(selectedCalendar);
const departmentCalendar = isDepartmentCalendar(selectedCalendar);
@ -670,19 +676,10 @@ export const CalendarCard = memo(function CalendarCard({
return (
<div className="space-y-2">
{visibleEvents.map((event) => {
const start = event.start ? new Date(event.start) : null;
const eventId = getLinkedCalendarEventId(event);
const linkedTasks = eventId ? linkedTasksByEventId[eventId] || [] : [];
const dateLabel =
start && !Number.isNaN(start.getTime())
? start.toLocaleString("ru-RU", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "Без даты";
const displayRange = getCalendarEventDisplayRange(event);
const calendarName = calendarNamesById.get(event.calendar);
return (
<div
@ -706,7 +703,31 @@ export const CalendarCard = memo(function CalendarCard({
</span>
)}
</div>
<p className="app-text-muted mt-0.5 text-[11px]">{dateLabel}</p>
{displayRange ? (
<div className="app-text-muted mt-1 space-y-0.5 text-[11px]">
<div className="flex min-w-0 items-center gap-1.5">
<CalendarDays size={12} className="shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate">{displayRange.dateLabel}</span>
{displayRange.isMultiDay ? (
<span className="app-badge app-badge-accent app-badge-interactive shrink-0 px-1.5 py-0.5 text-[10px] font-medium leading-none">
{displayRange.dayCountLabel}
</span>
) : null}
</div>
<div className="flex min-w-0 items-center gap-1.5">
<Clock3 size={12} className="shrink-0" aria-hidden="true" />
<span className="shrink-0">{displayRange.timeLabel}</span>
{selectedCalendarId === null && calendarName ? (
<>
<span aria-hidden="true">·</span>
<span className="min-w-0 truncate">{calendarName}</span>
</>
) : null}
</div>
</div>
) : (
<p className="app-text-muted mt-0.5 text-[11px]">Без даты</p>
)}
</button>
{linkedTasks.length > 0 ? (

View File

@ -4,6 +4,7 @@ import test from "node:test";
import {
calendarEventOverlapsRange,
getCalendarEventDateKeys,
getCalendarEventDisplayRange,
} from "./eventRange.ts";
test("event crossing the week start is included in the current week", () => {
@ -73,3 +74,57 @@ test("calendar day markers include a day when the event continues after midnight
["2026-08-31", "2026-09-01"],
);
});
test("calendar event display range marks events spanning several days", () => {
const range = getCalendarEventDisplayRange({
start: "2026-08-28T10:00:00",
end: "2026-08-30T18:00:00",
});
assert.ok(range);
assert.equal(range.isMultiDay, true);
assert.equal(range.dayCount, 3);
assert.equal(range.dayCountLabel, "3 дня");
assert.match(range.dateLabel, /28/);
assert.match(range.dateLabel, /30/);
assert.match(range.timeLabel, /10:00/);
assert.match(range.timeLabel, /18:00/);
});
test("midnight end does not add an empty calendar day", () => {
const range = getCalendarEventDisplayRange({
start: "2026-08-28T00:00:00",
end: "2026-08-30T00:00:00",
});
assert.ok(range);
assert.equal(range.isMultiDay, true);
assert.equal(range.dayCount, 2);
assert.equal(range.dayCountLabel, "2 дня");
assert.equal(range.timeLabel, "Весь день");
});
test("single-day event stays compact", () => {
const range = getCalendarEventDisplayRange({
start: "2026-08-28T10:00:00",
end: "2026-08-28T11:30:00",
});
assert.ok(range);
assert.equal(range.isMultiDay, false);
assert.equal(range.dayCount, 1);
assert.equal(range.dayCountLabel, "1 день");
assert.match(range.timeLabel, /10:00/);
assert.match(range.timeLabel, /11:30/);
});
test("full calendar day is labeled as all-day", () => {
const range = getCalendarEventDisplayRange({
start: "2026-09-01T00:00:00",
end: "2026-09-01T23:59:00",
});
assert.ok(range);
assert.equal(range.isMultiDay, false);
assert.equal(range.timeLabel, "Весь день");
});

View File

@ -3,6 +3,14 @@ export type CalendarEventInterval = {
end?: string | Date | null;
};
export type CalendarEventDisplayRange = {
dateLabel: string;
timeLabel: string;
dayCount: number;
dayCountLabel: string;
isMultiDay: boolean;
};
const pad = (value: number) => String(value).padStart(2, "0");
export const formatLocalDateKey = (date: Date): string =>
@ -14,6 +22,70 @@ const parseEventDate = (value: string | Date | null | undefined): Date | null =>
return Number.isNaN(date.getTime()) ? null : date;
};
const formatShortDate = (date: Date, includeWeekday = false) =>
date.toLocaleDateString("ru-RU", {
...(includeWeekday ? { weekday: "short" as const } : {}),
day: "numeric",
month: "short",
});
const formatTime = (date: Date) =>
date.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
const formatDayCount = (count: number) => {
const mod100 = count % 100;
if (mod100 >= 11 && mod100 <= 14) return `${count} дней`;
const mod10 = count % 10;
if (mod10 === 1) return `${count} день`;
if (mod10 >= 2 && mod10 <= 4) return `${count} дня`;
return `${count} дней`;
};
/**
* Компактное, но полное представление периода события для карточек.
* Для границы окончания в 00:00 показывается последний фактически занятый день.
*/
export function getCalendarEventDisplayRange(
event: CalendarEventInterval,
): CalendarEventDisplayRange | null {
const start = parseEventDate(event.start);
if (!start) return null;
const parsedEnd = parseEventDate(event.end);
const end = parsedEnd && parsedEnd > start ? parsedEnd : start;
const dateKeys = getCalendarEventDateKeys(event);
const dayCount = Math.max(1, dateKeys.length);
const isMultiDay = dayCount > 1;
let lastOccupiedDay = end;
if (parsedEnd && parsedEnd > start) {
lastOccupiedDay = new Date(parsedEnd.getTime() - 1);
}
const dateLabel = isMultiDay
? `${formatShortDate(start)} — ${formatShortDate(lastOccupiedDay)}`
: formatShortDate(start, true);
const startsAtMidnight = start.getHours() === 0 && start.getMinutes() === 0;
const endsAtMidnight = end.getHours() === 0 && end.getMinutes() === 0;
const endsAtDayEnd = end.getHours() === 23 && end.getMinutes() === 59;
const timeLabel = startsAtMidnight && (endsAtMidnight || endsAtDayEnd)
? "Весь день"
: end > start
? `${formatTime(start)}–${formatTime(end)}`
: formatTime(start);
return {
dateLabel,
timeLabel,
dayCount,
dayCountLabel: formatDayCount(dayCount),
isMultiDay,
};
}
/**
* Проверяет пересечение события с полуоткрытым диапазоном [start, end).
* Для событий без корректного окончания используется дата начала как момент времени.