Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion courses/admin_config/content.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
from django.contrib import admin
from django.db import transaction
from django.http import Http404
from django.urls import path

from courses.models import Course, CourseLesson, CourseModule, CourseTask, CourseTaskOption
from courses.models import (
Course,
CourseContentStatus,
CourseLesson,
CourseModule,
CourseTask,
CourseTaskOption,
)
from courses.services.export_course_results import build_course_results_export_response
from notifications.events import notify_course_access_opened

from .forms import CourseAdminForm, CourseModuleAdminForm, CourseTaskAdminForm
from .helpers import UserFileUploadAdminMixin
Expand Down Expand Up @@ -106,7 +115,14 @@ def export_results_view(self, request, object_id):
raise Http404("Курс не найден.")
return build_course_results_export_response(course)

@transaction.atomic
def save_model(self, request, obj, form, change):
previous_status = None
if change and obj.pk:
previous_status = (
Course.objects.filter(pk=obj.pk).values_list("status", flat=True).first()
)

avatar_upload = form.cleaned_data.get("avatar_upload")
if avatar_upload:
obj.avatar_file = self.create_user_file(request, avatar_upload)
Expand All @@ -120,6 +136,16 @@ def save_model(self, request, obj, form, change):
obj.header_cover_file = self.create_user_file(request, header_cover_upload)

super().save_model(request, obj, form, change)
if (
previous_status != CourseContentStatus.PUBLISHED
and obj.status == CourseContentStatus.PUBLISHED
and obj.partner_program_id is not None
):
notify_course_access_opened(
obj,
program=obj.partner_program,
actor=request.user,
)


@admin.register(CourseModule)
Expand Down
8 changes: 7 additions & 1 deletion news/services.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from typing import Any

from django.db import transaction

from news.models import News
from notifications.events import notify_program_news_published
from partner_programs.models import PartnerProgram
from projects.models import Project
from users.models import CustomUser
Expand Down Expand Up @@ -33,9 +36,12 @@ def create_user_news(
return News.objects.add_news(user, **data)


@transaction.atomic
def create_program_news(
program: PartnerProgram,
author,
data: dict[str, Any],
) -> News:
return News.objects.add_news(program, **data)
news = News.objects.add_news(program, **data)
notify_program_news_published(news, program=program, actor=author)
return news
57 changes: 57 additions & 0 deletions notifications/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ def _nextgen_notifications_enabled() -> bool:
return settings.NEXTGEN_SURFACE_ENABLED


def _program_member_ids(program_id: int):
from partner_programs.models import PartnerProgramUserProfile

return (
PartnerProgramUserProfile.objects.filter(
partner_program_id=program_id,
user_id__isnull=False,
)
.order_by()
.values_list("user_id", flat=True)
.distinct()
)


def notify_project_invite_created(invite) -> None:
"""Уведомляет пользователя о новом приглашении в проект."""
create_notification(
Expand Down Expand Up @@ -106,6 +120,49 @@ def notify_vacancy_response_resolved(response, *, actor, accepted: bool) -> None
)


def notify_program_news_published(news, *, program, actor) -> None:
"""Уведомляет участников программы о созданной новости программы."""
create_notifications(
recipient_ids=_program_member_ids(program.pk),
actor_id=actor.pk,
notification_type=Notification.Type.PROGRAM_NEWS_PUBLISHED,
title="Новая новость в программе",
message=f"В программе «{program.name}» опубликована новая новость.",
action_url=f"/office/program/{program.pk}",
event_key=_event_key("program-news", news.pk, "published"),
)


def notify_program_material_published(material, *, program, actor) -> None:
"""Уведомляет участников программы о новом материале программы."""
create_notifications(
recipient_ids=_program_member_ids(program.pk),
actor_id=actor.pk,
notification_type=Notification.Type.PROGRAM_MATERIAL_PUBLISHED,
title="Новый материал в программе",
message=(f"В программе «{program.name}» добавлен материал «{material.title}»."),
action_url=f"/office/program/{program.pk}",
event_key=_event_key("program-material", material.pk, "published"),
)


def notify_course_access_opened(course, *, program, actor) -> None:
"""Уведомляет участников программы об одном переходе курса в published."""
create_notifications(
recipient_ids=_program_member_ids(program.pk),
actor_id=actor.pk,
notification_type=Notification.Type.COURSE_ACCESS_OPENED,
title="Открыт доступ к курсу",
message=(f"В программе «{program.name}» открыт доступ к курсу «{course.title}»."),
action_url=f"/office/courses/{course.pk}",
event_key=_event_key(
"course-access",
course.pk,
course.datetime_updated.isoformat(),
),
)


def notify_team_invite_created(invite) -> None:
"""Уведомляет пользователя о новом приглашении в команду заявки."""
if not _nextgen_notifications_enabled():
Expand Down
44 changes: 44 additions & 0 deletions notifications/migrations/0002_add_program_notification_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by Django 4.2.11 on 2026-09-11 18:37

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("notifications", "0001_initial"),
]

operations = [
migrations.AlterField(
model_name="notification",
name="type",
field=models.CharField(
choices=[
("project_invite_created", "Приглашение в проект"),
("project_invite_accepted", "Приглашение в проект принято"),
("project_invite_declined", "Приглашение в проект отклонено"),
("project_invite_revoked", "Приглашение в проект отозвано"),
("vacancy_response_created", "Новый отклик на вакансию"),
("vacancy_response_accepted", "Отклик принят"),
("vacancy_response_declined", "Отклик отклонён"),
("program_news_published", "Новая новость программы"),
("program_material_published", "Новый материал программы"),
("course_access_opened", "Открыт доступ к курсу"),
("team_invite_created", "Приглашение в команду"),
("team_invite_accepted", "Приглашение в команду принято"),
("team_invite_declined", "Приглашение в команду отклонено"),
("team_invite_revoked", "Приглашение в команду отозвано"),
("application_submitted", "Заявка отправлена"),
("application_status_changed", "Статус заявки изменён"),
("submission_submitted", "Решение отправлено"),
("submission_status_changed", "Статус решения изменён"),
("expert_assignment_created", "Назначена экспертиза"),
("expert_assignment_revoked", "Экспертиза отозвана"),
("evaluation_submitted", "Оценка отправлена"),
("news_comment_created", "Новый комментарий"),
],
max_length=64,
),
),
]
12 changes: 12 additions & 0 deletions notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ class Type(models.TextChoices):
VACANCY_RESPONSE_CREATED = "vacancy_response_created", "Новый отклик на вакансию"
VACANCY_RESPONSE_ACCEPTED = "vacancy_response_accepted", "Отклик принят"
VACANCY_RESPONSE_DECLINED = "vacancy_response_declined", "Отклик отклонён"
PROGRAM_NEWS_PUBLISHED = (
"program_news_published",
"Новая новость программы",
)
PROGRAM_MATERIAL_PUBLISHED = (
"program_material_published",
"Новый материал программы",
)
COURSE_ACCESS_OPENED = "course_access_opened", "Открыт доступ к курсу"
TEAM_INVITE_CREATED = "team_invite_created", "Приглашение в команду"
TEAM_INVITE_ACCEPTED = "team_invite_accepted", "Приглашение в команду принято"
TEAM_INVITE_DECLINED = "team_invite_declined", "Приглашение в команду отклонено"
Expand Down Expand Up @@ -46,6 +55,9 @@ class Category(models.TextChoices):
Type.VACANCY_RESPONSE_CREATED: Category.VACANCY,
Type.VACANCY_RESPONSE_ACCEPTED: Category.VACANCY,
Type.VACANCY_RESPONSE_DECLINED: Category.VACANCY,
Type.PROGRAM_NEWS_PUBLISHED: Category.PROGRAM,
Type.PROGRAM_MATERIAL_PUBLISHED: Category.PROGRAM,
Type.COURSE_ACCESS_OPENED: Category.PROGRAM,
Type.TEAM_INVITE_CREATED: Category.PROGRAM,
Type.TEAM_INVITE_ACCEPTED: Category.PROGRAM,
Type.TEAM_INVITE_DECLINED: Category.PROGRAM,
Expand Down
25 changes: 25 additions & 0 deletions notifications/selectors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.conf import settings

from notifications.models import Notification


ANGULAR_NOTIFICATION_TYPES = (
Notification.Type.PROJECT_INVITE_CREATED,
Notification.Type.PROJECT_INVITE_ACCEPTED,
Notification.Type.PROJECT_INVITE_DECLINED,
Notification.Type.PROJECT_INVITE_REVOKED,
Notification.Type.VACANCY_RESPONSE_CREATED,
Notification.Type.VACANCY_RESPONSE_ACCEPTED,
Notification.Type.VACANCY_RESPONSE_DECLINED,
Notification.Type.PROGRAM_NEWS_PUBLISHED,
Notification.Type.PROGRAM_MATERIAL_PUBLISHED,
Notification.Type.COURSE_ACCESS_OPENED,
)


def get_visible_notifications(user):
"""Returns the notification surface available to the current frontend."""
queryset = Notification.objects.filter(recipient=user)
if settings.NEXTGEN_SURFACE_ENABLED:
return queryset
return queryset.filter(type__in=ANGULAR_NOTIFICATION_TYPES)
106 changes: 106 additions & 0 deletions notifications/tests/test_notification_visibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from django.test import SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient

from news.tests.helpers import create_user
from notifications.models import Notification
from notifications.selectors import ANGULAR_NOTIFICATION_TYPES


def create_typed_notification(*, recipient, notification_type, suffix):
return Notification.objects.create(
recipient=recipient,
type=notification_type,
category=Notification.TYPE_CATEGORY[notification_type],
title="Уведомление",
message="Сообщение",
action_url="/office/program/1",
event_key=f"visibility:{suffix}",
)


class AngularNotificationTypesTests(SimpleTestCase):
def test_angular_surface_contains_only_production_types(self):
self.assertEqual(
set(ANGULAR_NOTIFICATION_TYPES),
{
Notification.Type.PROJECT_INVITE_CREATED,
Notification.Type.PROJECT_INVITE_ACCEPTED,
Notification.Type.PROJECT_INVITE_DECLINED,
Notification.Type.PROJECT_INVITE_REVOKED,
Notification.Type.VACANCY_RESPONSE_CREATED,
Notification.Type.VACANCY_RESPONSE_ACCEPTED,
Notification.Type.VACANCY_RESPONSE_DECLINED,
Notification.Type.PROGRAM_NEWS_PUBLISHED,
Notification.Type.PROGRAM_MATERIAL_PUBLISHED,
Notification.Type.COURSE_ACCESS_OPENED,
},
)


@override_settings(NEXTGEN_SURFACE_ENABLED=False)
class NotificationVisibilityOffTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = create_user(prefix="notification-visibility-off")
self.client.force_authenticate(self.user)
self.visible = create_typed_notification(
recipient=self.user,
notification_type=Notification.Type.PROGRAM_NEWS_PUBLISHED,
suffix="visible",
)
self.hidden = create_typed_notification(
recipient=self.user,
notification_type=Notification.Type.APPLICATION_SUBMITTED,
suffix="hidden-application",
)

def test_list_and_counts_ignore_physical_nextgen_row(self):
response = self.client.get("/notifications/")
count_response = self.client.get("/notifications/unread-count/")

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["unread_count"], 1)
self.assertEqual(response.data["results"][0]["id"], self.visible.pk)
self.assertEqual(count_response.data, {"unread_count": 1})
self.assertTrue(Notification.objects.filter(pk=self.hidden.pk).exists())

def test_read_all_does_not_modify_hidden_row(self):
response = self.client.post("/notifications/read-all/")

self.assertEqual(response.data, {"updated": 1, "unread_count": 0})
self.visible.refresh_from_db()
self.hidden.refresh_from_db()
self.assertIsNotNone(self.visible.read_at)
self.assertIsNone(self.hidden.read_at)

def test_hidden_notification_cannot_be_read_by_id(self):
response = self.client.post(f"/notifications/{self.hidden.pk}/read/")

self.assertEqual(response.status_code, 404)
self.hidden.refresh_from_db()
self.assertIsNone(self.hidden.read_at)


@override_settings(NEXTGEN_SURFACE_ENABLED=True)
class NotificationVisibilityOnTests(TestCase):
def test_nextgen_row_is_visible_and_readable(self):
client = APIClient()
user = create_user(prefix="notification-visibility-on")
hidden_when_off = create_typed_notification(
recipient=user,
notification_type=Notification.Type.APPLICATION_SUBMITTED,
suffix="nextgen-on",
)
client.force_authenticate(user)

response = client.get("/notifications/")
read_response = client.post(f"/notifications/{hidden_when_off.pk}/read/")

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["unread_count"], 1)
self.assertEqual(response.data["results"][0]["id"], hidden_when_off.pk)
self.assertEqual(read_response.status_code, 200)
hidden_when_off.refresh_from_db()
self.assertIsNotNone(hidden_when_off.read_at)
Loading
Loading