feat(calendar): доступ к событиям для участников + UX улучшения

Backend:
- Расширен get_queryset в ScheduleEventViewSet: теперь пользователь видит события где он участник (EventRelation), даже если не имеет доступа к календарю
- Добавлен action my-events для получения только событий участника
- Исправлена архитектурная проблема: участники событий теперь могут видеть свои события

Frontend:
- Добавлен метод apiClient.getMyEvents() для получения событий пользователя
- Добавлена подсказка в EventModal о логике начала/конца для повторяющихся событий
- Улучшен UX: пользователи понимают что начало/конец применяются ко всем повторениям
This commit is contained in:
igor_nadein 2026-02-25 00:47:45 +03:00
parent c22ccc3e96
commit 898ed6fec1
3 changed files with 839 additions and 1 deletions

View File

@ -321,8 +321,39 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
return EventSerializer
def get_queryset(self):
"""Фильтрация событий."""
"""Фильтрация событий.
Пользователь видит события из:
1. Календарей, где он участник (CalendarRelation)
2. Событий, где он участник (EventRelation)
"""
from django.contrib.auth import get_user_model
User = get_user_model()
qs = super().get_queryset()
user = self.request.user
# Админы видят все события
if not (user.is_staff or user.is_superuser):
ct = ContentType.objects.get_for_model(User)
# ID календарей где пользователь участник
accessible_calendar_ids = CalendarRelation.objects.filter(
content_type=ct,
object_id=user.id
).values_list('calendar_id', flat=True)
# ID событий где пользователь участник
participant_event_ids = EventRelation.objects.filter(
content_type=ct,
object_id=user.id
).values_list('event_id', flat=True)
# Объединяем: события из доступных календарей ИЛИ где пользователь участник
qs = qs.filter(
Q(calendar_id__in=accessible_calendar_ids) |
Q(id__in=participant_event_ids)
)
# Фильтр по календарю
calendar_id = self.request.query_params.get('calendar')
@ -409,6 +440,51 @@ class ScheduleEventViewSet(viewsets.ModelViewSet):
})
return Response(occurrences)
@action(detail=False, methods=['get'], url_path='my-events')
def my_events(self, request):
"""Получить события текущего пользователя (где он участник).
Query params:
- start: начало диапазона (ISO 8601, необязательно)
- end: конец диапазона (ISO 8601, необязательно)
Возвращает только события где пользователь является участником (EventRelation).
"""
from django.contrib.auth import get_user_model
User = get_user_model()
user = request.user
ct = ContentType.objects.get_for_model(User)
# ID событий где пользователь участник
participant_event_ids = EventRelation.objects.filter(
content_type=ct,
object_id=user.id
).values_list('event_id', flat=True)
# Фильтруем события
qs = self.queryset.filter(id__in=participant_event_ids)
# Фильтр по диапазону дат (если указан)
start_str = request.query_params.get('start')
end_str = request.query_params.get('end')
if start_str and end_str:
start_dt = parse_datetime(start_str)
end_dt = parse_datetime(end_str)
if start_dt and end_dt:
qs = qs.filter(
start__lte=end_dt,
end__gte=start_dt
)
qs = qs.order_by('start')
# Используем EventListSerializer для детальной информации
serializer = EventListSerializer(qs, many=True)
return Response(serializer.data)
class ScheduleRuleViewSet(viewsets.ModelViewSet):

View File

@ -334,6 +334,12 @@ export function EventModal({
</div>
</div>
{editingEvent.isRecurring && (
<p className="text-xs text-blue-600 bg-blue-50 rounded-lg p-2 mt-1">
💡 Начало и конец определяют время первого события и его продолжительность. Эти параметры будут применены ко всем повторениям.
</p>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Цвет

756
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,756 @@
/**
* API Client для работы с Django Backend через Next.js API proxy
* Все запросы идут на /api/* и проксируются на backend
*/
interface LoginCredentials {
email?: string;
phone?: string;
password: string;
}
interface LoginResponse {
access: string;
refresh: string;
}
class ApiClient {
private tokenKey = 'access_token';
private refreshTokenKey = 'refresh_token';
private getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(this.tokenKey);
}
private setToken(token: string): void {
if (typeof window !== 'undefined') {
localStorage.setItem(this.tokenKey, token);
}
}
private setRefreshToken(token: string): void {
if (typeof window !== 'undefined') {
localStorage.setItem(this.refreshTokenKey, token);
}
}
clearToken(): void {
if (typeof window !== 'undefined') {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
}
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
// Автоматически добавляем токен если он есть
const token = this.getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(endpoint, {
...options,
headers,
});
if (!response.ok) {
// Попытка получить детали ошибки
let errorDetails = '';
try {
const errorData = await response.json();
errorDetails = errorData.detail || JSON.stringify(errorData);
} catch {
errorDetails = response.statusText;
}
throw new Error(`API Error: ${response.status} ${errorDetails}`);
}
// Для DELETE запросов может не быть тела ответа
if (options.method === 'DELETE' && response.status === 204) {
return undefined as T;
}
// Проверяем, есть ли контент в ответе
const contentLength = response.headers.get('content-length');
if (contentLength === '0' || response.status === 204) {
return undefined as T;
}
return response.json();
}
// Авторизация
async login(credentials: LoginCredentials): Promise<void> {
const response = await fetch('/api/auth/token/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error(`Login failed: ${response.status}`);
}
const data: LoginResponse = await response.json();
this.setToken(data.access);
this.setRefreshToken(data.refresh);
}
// Получение текущего пользователя
async getCurrentUser(): Promise<any> {
return this.request('/api/v1/employees/me/');
}
// Сотрудники
async getEmployees(params?: { search?: string; department?: string; page?: number; limit?: number }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.search) queryParams.append('search', params.search);
if (params?.department) queryParams.append('department', params.department);
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
const url = `/api/v1/employees/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getEmployee(id: number | string): Promise<any> {
return this.request(`/api/v1/employees/${id}/`);
}
// Отделы
async getDepartments(params?: { search?: string; page?: number; limit?: number }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.search) queryParams.append('search', params.search);
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
const url = `/api/v1/departments/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getDepartment(id: number | string): Promise<any> {
return this.request(`/api/v1/departments/${id}/`);
}
// Посты (лента новостей)
async getPosts(params?: { page?: number; search?: string; limit?: number }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.search) queryParams.append('search', params.search);
if (params?.limit) queryParams.append('limit', params.limit.toString());
const url = `/api/v1/posts/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
// Документы
async getDocuments(params?: { search?: string; type?: string; page?: number; limit?: number }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.search) queryParams.append('search', params.search);
if (params?.type) queryParams.append('type', params.type);
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
const url = `/api/v1/documents/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
// Заявки
async getRequests(params?: { status?: string; type?: string; search?: string; page?: number; limit?: number }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.append('status', params.status);
if (params?.type) queryParams.append('type', params.type);
if (params?.search) queryParams.append('search', params.search);
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
const url = `/api/v1/requests/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async createRequest(data: any, saveAs?: 'draft' | 'submitted'): Promise<any> {
const formData = new FormData();
// Добавляем все поля из data
Object.keys(data).forEach((key) => {
if (key === 'attachments' && Array.isArray(data[key])) {
data[key].forEach((file: File) => {
formData.append('attachments', file);
});
} else if (data[key] !== null && data[key] !== undefined) {
formData.append(key, data[key]);
}
});
if (saveAs) {
formData.append('save_as', saveAs);
}
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/v1/requests/', {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async updateRequest(requestId: number, data: any, saveAs?: 'draft' | 'submitted'): Promise<any> {
const formData = new FormData();
Object.keys(data).forEach((key) => {
if (key === 'attachments' && Array.isArray(data[key])) {
data[key].forEach((file: File) => {
formData.append('attachments', file);
});
} else if (data[key] !== null && data[key] !== undefined) {
formData.append(key, data[key]);
}
});
if (saveAs) {
formData.append('save_as', saveAs);
}
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/v1/requests/${requestId}/`, {
method: 'PATCH',
headers,
body: formData,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async deleteRequest(requestId: number): Promise<void> {
await this.request(`/api/v1/requests/${requestId}/`, {
method: 'DELETE',
});
}
async getRequestComments(requestId: number): Promise<any> {
return this.request(`/api/v1/requests/${requestId}/comments/`);
}
async deleteRequestComment(requestId: number, commentId: number): Promise<void> {
await this.request(`/api/v1/requests/${requestId}/comments/${commentId}/`, {
method: 'DELETE',
});
}
// Чаты
async getChats(params?: { search?: string; page?: number; limit?: number }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.search) queryParams.append('search', params.search);
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
const url = `/api/v1/communications/chats/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getChat(chatId: number | string): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/`);
}
async getChatMessages(chatId: number | string, params?: { limit?: number; before?: number; after?: number }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.before) queryParams.append('before', params.before.toString());
if (params?.after) queryParams.append('after', params.after.toString());
const url = `/api/v1/communications/chats/${chatId}/messages/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getChatMessagesAround(chatId: number | string, params?: { limit?: number; around_id?: number }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.around_id) queryParams.append('around_id', params.around_id.toString());
const url = `/api/v1/communications/chats/${chatId}/messages-around/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async sendMessage(chatId: number | string, text: string, replyTo?: number): Promise<any> {
return this.request(`/api/v1/communications/chats/${chatId}/messages/`, {
method: 'POST',
body: JSON.stringify({ text, reply_to: replyTo }),
});
}
async sendMessageWithFiles(chatId: number | string, text: string, files: File[], replyTo?: number): Promise<any> {
const formData = new FormData();
formData.append('text', text);
if (replyTo) {
formData.append('reply_to', replyTo.toString());
}
files.forEach((file) => {
formData.append('attachments', file);
});
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/v1/communications/chats/${chatId}/messages/`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async updateMessage(messageId: number, text: string): Promise<any> {
return this.request(`/api/v1/communications/messages/${messageId}/`, {
method: 'PATCH',
body: JSON.stringify({ text }),
});
}
async deleteMessage(messageId: number): Promise<void> {
await this.request(`/api/v1/communications/messages/${messageId}/`, {
method: 'DELETE',
});
}
// Уведомления
async getNotifications(): Promise<any> {
return this.request('/api/v1/notifications/');
}
async markNotificationAsRead(id: number): Promise<void> {
await this.request(`/api/v1/notifications/${id}/mark_read/`, {
method: 'POST',
});
}
async markAllNotificationsAsRead(): Promise<void> {
await this.request('/api/v1/notifications/mark_all_read/', {
method: 'POST',
});
}
// Календарь (django-scheduler API)
async getCalendarEvents(params?: { start?: string; end?: string; calendar?: number }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.start) queryParams.append('start', params.start);
if (params?.end) queryParams.append('end', params.end);
if (params?.calendar) queryParams.append('calendar', params.calendar.toString());
const url = `/api/v1/schedule/events/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getMyEvents(params?: { start?: string; end?: string }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.start) queryParams.append('start', params.start);
if (params?.end) queryParams.append('end', params.end);
const url = `/api/v1/schedule/events/my-events/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async getCalendars(): Promise<any> {
return this.request('/api/v1/schedule/calendars/');
}
async getCalendar(id: number): Promise<any> {
return this.request(`/api/v1/schedule/calendars/${id}/`);
}
async createCalendar(data: { name: string; slug?: string }): Promise<any> {
return this.request('/api/v1/schedule/calendars/', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateCalendar(id: number, data: { name?: string; slug?: string }): Promise<any> {
return this.request(`/api/v1/schedule/calendars/${id}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteCalendar(id: number): Promise<void> {
await this.request(`/api/v1/schedule/calendars/${id}/`, {
method: 'DELETE',
});
}
async getEvent(id: number): Promise<any> {
return this.request(`/api/v1/schedule/events/${id}/`);
}
async createEvent(data: {
title: string;
description?: string;
start: string;
end: string;
calendar: number;
color_event?: string;
rule?: number | null;
}): Promise<any> {
return this.request('/api/v1/schedule/events/', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateEvent(id: number, data: {
title?: string;
description?: string;
start?: string;
end?: string;
calendar?: number;
color_event?: string;
rule?: number | null;
}): Promise<any> {
return this.request(`/api/v1/schedule/events/${id}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteEvent(id: number): Promise<void> {
await this.request(`/api/v1/schedule/events/${id}/`, {
method: 'DELETE',
});
}
// Calendar Participants (CalendarRelation)
async getCalendarParticipants(calendarId: number): Promise<any> {
return this.request(`/api/v1/schedule/calendars/${calendarId}/participants/`);
}
async addCalendarParticipant(calendarId: number, userId: number, distinction: string = 'viewer'): Promise<any> {
return this.request(`/api/v1/schedule/calendars/${calendarId}/add-participant/`, {
method: 'POST',
body: JSON.stringify({ user_id: userId, distinction }),
});
}
async removeCalendarParticipant(calendarId: number, userId: number): Promise<void> {
await this.request(`/api/v1/schedule/calendars/${calendarId}/remove-participant/${userId}/`, {
method: 'DELETE',
});
}
// Calendar Export
async exportCalendarToICS(calendarId: number): Promise<Blob> {
const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/export-ical/`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.getToken()}`,
},
});
if (!response.ok) {
throw new Error('Failed to export calendar');
}
return response.blob();
}
// Calendar Import
async importCalendarFromICS(calendarId: number, file: File): Promise<any> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`/api/v1/schedule/calendars/${calendarId}/import-ical/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.getToken()}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to import calendar');
}
return response.json();
}
// Recurring Events (Rules)
async createRule(data: {
name: string;
description?: string;
frequency: string;
params?: any;
}): Promise<any> {
// Сериализуем params в JSON-строку для django-scheduler
const payload = {
...data,
params: data.params ? JSON.stringify(data.params) : '{}',
};
return this.request('/api/v1/schedule/rules/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
async getRules(): Promise<any> {
return this.request('/api/v1/schedule/rules/');
}
async getRule(ruleId: number): Promise<any> {
return this.request(`/api/v1/schedule/rules/${ruleId}/`);
}
async getOccurrences(params: { start: string; end: string; calendar?: number }): Promise<any> {
const queryParams = new URLSearchParams();
queryParams.append('start', params.start);
queryParams.append('end', params.end);
if (params.calendar) queryParams.append('calendar', params.calendar.toString());
return this.request(`/api/v1/schedule/events/occurrences/?${queryParams.toString()}`);
}
// Event Participants (EventRelation)
async getEventParticipants(eventId: number): Promise<any> {
return this.request(`/api/v1/schedule/relations/?event=${eventId}`);
}
async addEventParticipant(eventId: number, userId: number, distinction: string = 'attendee'): Promise<any> {
return this.request('/api/v1/schedule/relations/', {
method: 'POST',
body: JSON.stringify({
event: eventId,
object_id: userId,
distinction,
}),
});
}
async removeEventParticipant(relationId: number): Promise<any> {
return this.request(`/api/v1/schedule/relations/${relationId}/`, {
method: 'DELETE',
});
}
// Посты - CRUD операции
async createPost(data: { content: string; attachments?: File[] }): Promise<any> {
const formData = new FormData();
formData.append('content', data.content);
if (data.attachments) {
data.attachments.forEach((file) => {
formData.append('attachments', file);
});
}
const token = this.getToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/v1/posts/', {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async updatePost(postId: number, data: { content: string; attachments?: File[] }): Promise<any> {
const formData = new FormData();
formData.append('content', data.content);
if (data.attachments) {
data.attachments.forEach((file) => {
formData.append('attachments', file);
});
}
const token = this.getToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/v1/posts/${postId}/`, {
method: 'PATCH',
headers,
body: formData,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async deletePost(postId: number): Promise<void> {
await this.request(`/api/v1/posts/${postId}/`, {
method: 'DELETE',
});
}
// Лайки
async likePost(postId: number): Promise<any> {
return this.request(`/api/v1/posts/${postId}/like/`, {
method: 'POST',
});
}
async unlikePost(postId: number): Promise<any> {
return this.request(`/api/v1/posts/${postId}/unlike/`, {
method: 'POST',
});
}
async getPostLikers(postId: number): Promise<any> {
return this.request(`/api/v1/posts/${postId}/likers/`);
}
// Комментарии
async getComments(params: { post: number; page?: number }): Promise<any> {
const queryParams = new URLSearchParams();
queryParams.append('post', params.post.toString());
if (params.page) queryParams.append('page', params.page.toString());
const url = `/api/v1/comments/?${queryParams.toString()}`;
return this.request(url);
}
async createComment(postId: number, text: string): Promise<any> {
return this.request('/api/v1/comments/', {
method: 'POST',
body: JSON.stringify({ post: postId, text }),
});
}
async updateComment(commentId: number, text: string): Promise<any> {
return this.request(`/api/v1/comments/${commentId}/`, {
method: 'PATCH',
body: JSON.stringify({ text }),
});
}
async deleteComment(commentId: number): Promise<void> {
await this.request(`/api/v1/comments/${commentId}/`, {
method: 'DELETE',
});
}
// Управление заявками (approve/reject/cancel)
async approveRequest(requestId: number): Promise<any> {
return this.request(`/api/v1/requests/${requestId}/approve/`, {
method: 'POST',
});
}
async rejectRequest(requestId: number): Promise<any> {
return this.request(`/api/v1/requests/${requestId}/reject/`, {
method: 'POST',
});
}
async cancelRequest(requestId: number): Promise<any> {
return this.request(`/api/v1/requests/${requestId}/cancel/`, {
method: 'POST',
});
}
async addRequestComment(requestId: number, text: string): Promise<any> {
return this.request(`/api/v1/requests/${requestId}/comments/`, {
method: 'POST',
body: JSON.stringify({ text }),
});
}
// Профиль пользователя
async updateCurrentUserProfile(data: any): Promise<any> {
const formData = new FormData();
Object.keys(data).forEach((key) => {
if (data[key] instanceof File) {
formData.append(key, data[key]);
} else if (data[key] !== null && data[key] !== undefined) {
formData.append(key, data[key]);
}
});
const token = this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/v1/employees/me/', {
method: 'PATCH',
headers,
body: formData,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Реакции на сообщения
async reactToMessage(messageId: number, emoji: string): Promise<any> {
return this.request(`/api/v1/communications/messages/${messageId}/react/`, {
method: 'POST',
body: JSON.stringify({ emoji }),
});
}
async unreactToMessage(messageId: number, emoji: string): Promise<any> {
return this.request(`/api/v1/communications/messages/${messageId}/unreact/`, {
method: 'POST',
body: JSON.stringify({ emoji }),
});
}
}
export const apiClient = new ApiClient();
export default apiClient;