Archived
feat: implement group EPUB upload and section splitting (US #29)
Backend: - Create hermes Django app with models: ReadingGroup, GroupBook, Section, ReadingSchedule, MemberProgress - EPUB section splitting service with automatic detection and reading time estimation - Section recommendation engine for 4-week meeting schedule - REST API endpoints for groups, books, sections, schedule, and member progress - Manual section adjustment (merge/split) support Frontend: - GroupsPage: list/create reading groups - GroupDetailPage: manage members, upload EPUB to group, view group books - GroupBookPage: section breakdown with merge/split controls, reading schedule, member progress - API client and TypeScript types for all group operations - i18n keys for English and Spanish Shared: - Group-related types and API endpoint constants in packages/shared
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.groups.models import (
|
||||
GroupBook,
|
||||
MemberProgress,
|
||||
ReadingGroup,
|
||||
ReadingGroupMembership,
|
||||
ReadingSchedule,
|
||||
Section,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ReadingGroup)
|
||||
class ReadingGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "admin", "created_at"]
|
||||
search_fields = ["name", "admin__email"]
|
||||
|
||||
|
||||
@admin.register(ReadingGroupMembership)
|
||||
class ReadingGroupMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "group", "role", "joined_at"]
|
||||
list_filter = ["role", "group"]
|
||||
|
||||
|
||||
@admin.register(GroupBook)
|
||||
class GroupBookAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "group", "status", "uploaded_by", "created_at"]
|
||||
list_filter = ["status"]
|
||||
search_fields = ["title", "group__name"]
|
||||
|
||||
|
||||
@admin.register(Section)
|
||||
class SectionAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "group_book", "order", "estimated_reading_minutes"]
|
||||
ordering = ["group_book", "order"]
|
||||
|
||||
|
||||
@admin.register(ReadingSchedule)
|
||||
class ReadingScheduleAdmin(admin.ModelAdmin):
|
||||
list_display = ["group_book", "meeting_number", "week_date"]
|
||||
ordering = ["group_book", "meeting_number"]
|
||||
|
||||
|
||||
@admin.register(MemberProgress)
|
||||
class MemberProgressAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "group_book", "current_section", "updated_at"]
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GroupsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.groups"
|
||||
verbose_name = "Reading Groups"
|
||||
@@ -0,0 +1,154 @@
|
||||
# Generated by Django 5.1.7 on 2026-06-20 19:21
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('books', '0003_readingprogress_epub_location'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReadingGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(db_index=True, max_length=256)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('admin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='administered_groups', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Reading Group',
|
||||
'verbose_name_plural': 'Reading Groups',
|
||||
'db_table': 'groups_reading_group',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GroupBook',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(db_index=True, max_length=512)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('replaced', 'Replaced')], db_index=True, default='active', max_length=16)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_books', to='books.ebook')),
|
||||
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uploaded_group_books', to=settings.AUTH_USER_MODEL)),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_books', to='groups.readinggroup')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Group Book',
|
||||
'verbose_name_plural': 'Group Books',
|
||||
'db_table': 'groups_group_book',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReadingGroupMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Reading Group Membership',
|
||||
'verbose_name_plural': 'Reading Group Memberships',
|
||||
'db_table': 'groups_membership',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='readinggroup',
|
||||
name='members',
|
||||
field=models.ManyToManyField(related_name='reading_groups', through='groups.ReadingGroupMembership', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReadingSchedule',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('meeting_number', models.PositiveIntegerField()),
|
||||
('week_date', models.DateField()),
|
||||
('section_ids', models.JSONField(default=list, help_text='Ordered list of Section IDs for this meeting')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='groups.groupbook')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Reading Schedule',
|
||||
'verbose_name_plural': 'Reading Schedules',
|
||||
'db_table': 'groups_reading_schedule',
|
||||
'ordering': ['meeting_number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Section',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=512)),
|
||||
('order', models.PositiveIntegerField(db_index=True)),
|
||||
('start_chapter_index', models.PositiveIntegerField(default=0)),
|
||||
('end_chapter_index', models.PositiveIntegerField(default=0)),
|
||||
('estimated_reading_minutes', models.PositiveIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='groups.groupbook')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Section',
|
||||
'verbose_name_plural': 'Sections',
|
||||
'db_table': 'groups_section',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MemberProgress',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('completed_sections', models.JSONField(default=list, help_text='Ordered list of completed Section IDs')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('group_book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_progress', to='groups.groupbook')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_progress', to=settings.AUTH_USER_MODEL)),
|
||||
('current_section', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_readers', to='groups.section')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Member Progress',
|
||||
'verbose_name_plural': 'Member Progress',
|
||||
'db_table': 'groups_member_progress',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='groupbook',
|
||||
index=models.Index(fields=['group', 'status'], name='groups_grou_group_i_afdbf7_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='readinggroupmembership',
|
||||
unique_together={('user', 'group')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='readingschedule',
|
||||
index=models.Index(fields=['group_book', 'meeting_number'], name='groups_read_group_b_2fc416_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='readingschedule',
|
||||
unique_together={('group_book', 'meeting_number')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='section',
|
||||
index=models.Index(fields=['group_book', 'order'], name='groups_sect_group_b_e2810a_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='section',
|
||||
unique_together={('group_book', 'order')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='memberprogress',
|
||||
unique_together={('user', 'group_book')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from apps.books.models import EBook
|
||||
|
||||
|
||||
class ReadingGroup(models.Model):
|
||||
"""A reading group with an admin and members."""
|
||||
|
||||
name = models.CharField(max_length=256, db_index=True)
|
||||
description = models.TextField(blank=True, default="")
|
||||
admin = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="administered_groups",
|
||||
)
|
||||
members = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
through="ReadingGroupMembership",
|
||||
related_name="reading_groups",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "groups_reading_group"
|
||||
verbose_name = "Reading Group"
|
||||
verbose_name_plural = "Reading Groups"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class ReadingGroupMembership(models.Model):
|
||||
class Role(models.TextChoices):
|
||||
ADMIN = "admin", "Admin"
|
||||
MEMBER = "member", "Member"
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
group = models.ForeignKey(ReadingGroup, on_delete=models.CASCADE, related_name="memberships")
|
||||
role = models.CharField(max_length=16, choices=Role.choices, default=Role.MEMBER)
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "groups_membership"
|
||||
unique_together = [("user", "group")]
|
||||
verbose_name = "Reading Group Membership"
|
||||
verbose_name_plural = "Reading Group Memberships"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} in {self.group.name} ({self.role})"
|
||||
|
||||
|
||||
class GroupBook(models.Model):
|
||||
"""Links an uploaded EBook to a reading group."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "Active"
|
||||
REPLACED = "replaced", "Replaced"
|
||||
|
||||
group = models.ForeignKey(
|
||||
ReadingGroup, on_delete=models.CASCADE, related_name="group_books"
|
||||
)
|
||||
ebook = models.ForeignKey(
|
||||
EBook, on_delete=models.CASCADE, related_name="group_books"
|
||||
)
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="uploaded_group_books"
|
||||
)
|
||||
title = models.CharField(max_length=512, db_index=True)
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.ACTIVE, db_index=True
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "groups_group_book"
|
||||
verbose_name = "Group Book"
|
||||
verbose_name_plural = "Group Books"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [models.Index(fields=["group", "status"])]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.group.name} — {self.title}"
|
||||
|
||||
|
||||
class Section(models.Model):
|
||||
"""A section/chunk of a GroupBook for group reading."""
|
||||
|
||||
group_book = models.ForeignKey(
|
||||
GroupBook, on_delete=models.CASCADE, related_name="sections"
|
||||
)
|
||||
title = models.CharField(max_length=512)
|
||||
order = models.PositiveIntegerField(db_index=True)
|
||||
start_chapter_index = models.PositiveIntegerField(default=0)
|
||||
end_chapter_index = models.PositiveIntegerField(default=0)
|
||||
estimated_reading_minutes = models.PositiveIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "groups_section"
|
||||
verbose_name = "Section"
|
||||
verbose_name_plural = "Sections"
|
||||
ordering = ["order"]
|
||||
unique_together = [("group_book", "order")]
|
||||
indexes = [models.Index(fields=["group_book", "order"])]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.group_book.title} — Section {self.order}: {self.title}"
|
||||
|
||||
|
||||
class ReadingSchedule(models.Model):
|
||||
"""Reading schedule mapping sections to weekly meetings."""
|
||||
|
||||
group_book = models.ForeignKey(
|
||||
GroupBook, on_delete=models.CASCADE, related_name="schedules"
|
||||
)
|
||||
meeting_number = models.PositiveIntegerField()
|
||||
week_date = models.DateField()
|
||||
section_ids = models.JSONField(default=list, help_text="Ordered list of Section IDs for this meeting")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "groups_reading_schedule"
|
||||
verbose_name = "Reading Schedule"
|
||||
verbose_name_plural = "Reading Schedules"
|
||||
ordering = ["meeting_number"]
|
||||
unique_together = [("group_book", "meeting_number")]
|
||||
indexes = [models.Index(fields=["group_book", "meeting_number"])]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.group_book.title} — Meeting {self.meeting_number}"
|
||||
|
||||
|
||||
class MemberProgress(models.Model):
|
||||
"""Tracks a member's progress through sections of a group book."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="group_progress"
|
||||
)
|
||||
group_book = models.ForeignKey(
|
||||
GroupBook, on_delete=models.CASCADE, related_name="member_progress"
|
||||
)
|
||||
current_section = models.ForeignKey(
|
||||
Section, on_delete=models.SET_NULL, null=True, blank=True, related_name="current_readers"
|
||||
)
|
||||
completed_sections = models.JSONField(
|
||||
default=list, help_text="Ordered list of completed Section IDs"
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "groups_member_progress"
|
||||
verbose_name = "Member Progress"
|
||||
verbose_name_plural = "Member Progress"
|
||||
unique_together = [("user", "group_book")]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} — {self.group_book.title}"
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.groups.models import (
|
||||
GroupBook,
|
||||
MemberProgress,
|
||||
ReadingGroup,
|
||||
ReadingGroupMembership,
|
||||
ReadingSchedule,
|
||||
Section,
|
||||
)
|
||||
from apps.books.serializers import EBookListSerializer
|
||||
|
||||
|
||||
class ReadingGroupMembershipSerializer(serializers.ModelSerializer):
|
||||
user_email = serializers.CharField(source="user.email", read_only=True)
|
||||
user_username = serializers.CharField(source="user.username", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ReadingGroupMembership
|
||||
fields = [
|
||||
"id", "user", "user_email", "user_username",
|
||||
"role", "joined_at",
|
||||
]
|
||||
read_only_fields = ["id", "joined_at"]
|
||||
|
||||
|
||||
class ReadingGroupListSerializer(serializers.ModelSerializer):
|
||||
member_count = serializers.SerializerMethodField()
|
||||
admin_email = serializers.CharField(source="admin.email", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ReadingGroup
|
||||
fields = [
|
||||
"id", "name", "description", "admin", "admin_email",
|
||||
"member_count", "created_at", "updated_at",
|
||||
]
|
||||
|
||||
def get_member_count(self, obj: ReadingGroup) -> int:
|
||||
return obj.memberships.count()
|
||||
|
||||
|
||||
class ReadingGroupDetailSerializer(serializers.ModelSerializer):
|
||||
admin_email = serializers.CharField(source="admin.email", read_only=True)
|
||||
members = ReadingGroupMembershipSerializer(source="memberships", many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ReadingGroup
|
||||
fields = [
|
||||
"id", "name", "description", "admin", "admin_email",
|
||||
"members", "created_at", "updated_at",
|
||||
]
|
||||
|
||||
|
||||
class ReadingGroupCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ReadingGroup
|
||||
fields = ["name", "description"]
|
||||
|
||||
|
||||
class AddMemberSerializer(serializers.Serializer):
|
||||
user_id = serializers.IntegerField()
|
||||
|
||||
def validate_user_id(self, value: int) -> int:
|
||||
from django.conf import settings
|
||||
User = settings.AUTH_USER_MODEL
|
||||
if not User.objects.filter(id=value).exists():
|
||||
raise serializers.ValidationError("User not found.")
|
||||
return value
|
||||
|
||||
|
||||
class SectionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Section
|
||||
fields = [
|
||||
"id", "title", "order", "start_chapter_index",
|
||||
"end_chapter_index", "estimated_reading_minutes", "created_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
|
||||
class SectionAdjustSerializer(serializers.Serializer):
|
||||
"""Payload for manual section adjustments (merge/split)."""
|
||||
operation = serializers.ChoiceField(choices=["merge", "split"])
|
||||
section_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(), min_length=1,
|
||||
help_text="For merge: list of section IDs to merge. For split: [section_id] to split."
|
||||
)
|
||||
split_at = serializers.IntegerField(
|
||||
required=False, default=2, min_value=2,
|
||||
help_text="Number of new sections when splitting"
|
||||
)
|
||||
|
||||
|
||||
class ReadingScheduleSerializer(serializers.ModelSerializer):
|
||||
section_details = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ReadingSchedule
|
||||
fields = [
|
||||
"id", "meeting_number", "week_date", "section_ids",
|
||||
"section_details", "created_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
def get_section_details(self, obj: ReadingSchedule) -> list[dict]:
|
||||
sections = Section.objects.filter(
|
||||
id__in=obj.section_ids, group_book=obj.group_book
|
||||
).order_by("order")
|
||||
return SectionSerializer(sections, many=True).data
|
||||
|
||||
|
||||
class MemberProgressSerializer(serializers.ModelSerializer):
|
||||
user_email = serializers.CharField(source="user.email", read_only=True)
|
||||
user_username = serializers.CharField(source="user.username", read_only=True)
|
||||
current_section_title = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MemberProgress
|
||||
fields = [
|
||||
"id", "user", "user_email", "user_username",
|
||||
"current_section", "current_section_title",
|
||||
"completed_sections", "updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "updated_at"]
|
||||
|
||||
def get_current_section_title(self, obj: MemberProgress) -> str | None:
|
||||
if obj.current_section:
|
||||
return obj.current_section.title
|
||||
return None
|
||||
|
||||
|
||||
class GroupBookListSerializer(serializers.ModelSerializer):
|
||||
ebook = EBookListSerializer(read_only=True)
|
||||
uploaded_by_email = serializers.CharField(source="uploaded_by.email", read_only=True)
|
||||
section_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = GroupBook
|
||||
fields = [
|
||||
"id", "title", "status", "ebook", "uploaded_by",
|
||||
"uploaded_by_email", "section_count", "created_at", "updated_at",
|
||||
]
|
||||
|
||||
def get_section_count(self, obj: GroupBook) -> int:
|
||||
return obj.sections.count()
|
||||
|
||||
|
||||
class GroupBookDetailSerializer(serializers.ModelSerializer):
|
||||
ebook = EBookListSerializer(read_only=True)
|
||||
uploaded_by_email = serializers.CharField(source="uploaded_by.email", read_only=True)
|
||||
sections = SectionSerializer(many=True, read_only=True)
|
||||
schedules = ReadingScheduleSerializer(source="schedules", many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = GroupBook
|
||||
fields = [
|
||||
"id", "group", "title", "status", "ebook",
|
||||
"uploaded_by", "uploaded_by_email",
|
||||
"sections", "schedules", "created_at", "updated_at",
|
||||
]
|
||||
|
||||
|
||||
class GroupBookCreateSerializer(serializers.Serializer):
|
||||
ebook_id = serializers.IntegerField()
|
||||
title = serializers.CharField(max_length=512, required=False)
|
||||
|
||||
def validate_ebook_id(self, value: int) -> int:
|
||||
from apps.books.models import EBook
|
||||
if not EBook.objects.filter(id=value).exists():
|
||||
raise serializers.ValidationError("EBook not found.")
|
||||
# Check format is EPUB
|
||||
ebook = EBook.objects.get(id=value)
|
||||
if ebook.format != "epub":
|
||||
raise serializers.ValidationError("Only EPUB format is supported for group books.")
|
||||
return value
|
||||
|
||||
def validate(self, data: dict) -> dict:
|
||||
request = self.context.get("request")
|
||||
if request and not data.get("title"):
|
||||
from apps.books.models import EBook
|
||||
ebook = EBook.objects.get(id=data["ebook_id"])
|
||||
data["title"] = ebook.title
|
||||
return data
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Automatic section detection and meeting recommendation for group books."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from apps.books.models import BookChapter, EBook
|
||||
from apps.groups.models import GroupBook, ReadingSchedule, Section
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WORDS_PER_MINUTE = 250 # Average reading speed
|
||||
|
||||
|
||||
def _fetch_chapter_text(ebook: EBook, chapter: BookChapter) -> str:
|
||||
"""Extract plain text from a chapter for word counting."""
|
||||
try:
|
||||
from ebooklib import epub
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
try:
|
||||
book = epub.read_epub(ebook.file.path)
|
||||
href = chapter.href or ""
|
||||
for item in book.get_items():
|
||||
item_name = item.get_name() or ""
|
||||
if href and (item_name.endswith(href) or href.endswith(item_name)):
|
||||
content = item.get_content()
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
body = soup.find("body")
|
||||
if body:
|
||||
return body.get_text(separator=" ", strip=True)
|
||||
return soup.get_text(separator=" ", strip=True)
|
||||
return ""
|
||||
except Exception:
|
||||
logger.exception("Failed to extract text for chapter %s", chapter.id)
|
||||
return ""
|
||||
|
||||
|
||||
def _estimate_reading_minutes(text: str) -> int:
|
||||
"""Estimate reading time based on word count at WORDS_PER_MINUTE."""
|
||||
word_count = len(text.split())
|
||||
if word_count == 0:
|
||||
return 1
|
||||
return max(1, round(word_count / WORDS_PER_MINUTE))
|
||||
|
||||
|
||||
def detect_sections(group_book: GroupBook) -> list[dict[str, Any]]:
|
||||
"""Auto-detect sections from a GroupBook's chapters.
|
||||
|
||||
Groups consecutive chapters into logical sections based on TOC structure.
|
||||
Top-level TOC entries become sections; if there are very few (< 3),
|
||||
groups of ~5 chapters become sections instead.
|
||||
"""
|
||||
ebook = group_book.ebook
|
||||
chapters = list(
|
||||
BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
|
||||
)
|
||||
|
||||
if not chapters:
|
||||
return []
|
||||
|
||||
top_level = [ch for ch in chapters if not ch.children or len(ch.children) == 0]
|
||||
has_children = [ch for ch in chapters if ch.children and len(ch.children) > 0]
|
||||
|
||||
sections: list[dict[str, Any]] = []
|
||||
|
||||
if len(has_children) >= 3:
|
||||
# Use TOC structure: each top-level chapter (with children) is a section
|
||||
for idx, ch in enumerate(has_children):
|
||||
# Find all child chapters belonging to this parent
|
||||
child_indices = _collect_child_indices(chapters, ch, idx)
|
||||
start_idx = ch.index
|
||||
end_idx = child_indices[-1] if child_indices else start_idx
|
||||
|
||||
section_chapters = [c for c in chapters if start_idx <= c.index <= end_idx]
|
||||
total_text = ""
|
||||
for sc in section_chapters:
|
||||
total_text += " " + _fetch_chapter_text(ebook, sc)
|
||||
|
||||
sections.append({
|
||||
"title": ch.title,
|
||||
"order": idx + 1,
|
||||
"start_chapter_index": start_idx,
|
||||
"end_chapter_index": end_idx + 1,
|
||||
"estimated_reading_minutes": _estimate_reading_minutes(total_text),
|
||||
})
|
||||
else:
|
||||
# Group chapters into chunks of ~5
|
||||
chunk_size = max(1, len(top_level) // 6 if len(top_level) > 6 else 5)
|
||||
chunk_size = max(3, min(chunk_size, 10))
|
||||
|
||||
group_start = 0
|
||||
section_order = 1
|
||||
total = len(top_level) or len(chapters)
|
||||
source = top_level or chapters
|
||||
|
||||
while group_start < total:
|
||||
group_end = min(group_start + chunk_size, total)
|
||||
chunk = source[group_start:group_end]
|
||||
|
||||
total_text = ""
|
||||
for ch in chunk:
|
||||
total_text += " " + _fetch_chapter_text(ebook, ch)
|
||||
|
||||
first_title = chunk[0].title if chunk else "Section"
|
||||
last_title = chunk[-1].title if len(chunk) > 1 else ""
|
||||
title = f"{first_title}" if not last_title or first_title == last_title else f"{first_title} — {last_title}"
|
||||
|
||||
sections.append({
|
||||
"title": title,
|
||||
"order": section_order,
|
||||
"start_chapter_index": chunk[0].index,
|
||||
"end_chapter_index": chunk[-1].index + 1,
|
||||
"estimated_reading_minutes": _estimate_reading_minutes(total_text),
|
||||
})
|
||||
group_start = group_end
|
||||
section_order += 1
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def _collect_child_indices(chapters: list[BookChapter], parent: BookChapter, parent_idx: int) -> list[int]:
|
||||
"""Collect indices of all chapters that are children of the given parent TOC entry."""
|
||||
indices: list[int] = [parent.index]
|
||||
child_hrefs: set[str] = set()
|
||||
for child in parent.children:
|
||||
if isinstance(child, dict):
|
||||
child_hrefs.add(child.get("href", ""))
|
||||
elif hasattr(child, "href"):
|
||||
child_hrefs.add(getattr(child, "href", ""))
|
||||
|
||||
for ch in chapters:
|
||||
if ch.index == parent.index:
|
||||
continue
|
||||
if ch.href in child_hrefs or any(
|
||||
ch.href.endswith(h) or h.endswith(ch.href) for h in child_hrefs
|
||||
):
|
||||
indices.append(ch.index)
|
||||
|
||||
# Also include chapters between this parent and the next parent
|
||||
if parent_idx + 1 < len(chapters):
|
||||
next_parent = chapters[parent_idx + 1]
|
||||
for ch in chapters:
|
||||
if parent.index < ch.index < next_parent.index:
|
||||
indices.append(ch.index)
|
||||
|
||||
return sorted(set(indices))
|
||||
|
||||
|
||||
def apply_sections(group_book: GroupBook) -> list[Section]:
|
||||
"""Detect sections and persist them to the database, replacing existing ones."""
|
||||
Section.objects.filter(group_book=group_book).delete()
|
||||
sections_data = detect_sections(group_book)
|
||||
created: list[Section] = []
|
||||
for data in sections_data:
|
||||
section = Section.objects.create(
|
||||
group_book=group_book,
|
||||
title=data["title"],
|
||||
order=data["order"],
|
||||
start_chapter_index=data["start_chapter_index"],
|
||||
end_chapter_index=data["end_chapter_index"],
|
||||
estimated_reading_minutes=data["estimated_reading_minutes"],
|
||||
)
|
||||
created.append(section)
|
||||
return created
|
||||
|
||||
|
||||
def recommend_meetings(group_book: GroupBook, num_meetings: int = 4) -> list[dict[str, Any]]:
|
||||
"""Recommend which sections to assign to each weekly meeting.
|
||||
|
||||
Distributes sections across meetings, trying to balance total reading time.
|
||||
Returns a list of meeting assignments ready for schedule creation.
|
||||
"""
|
||||
sections = list(
|
||||
Section.objects.filter(group_book=group_book).order_by("order")
|
||||
)
|
||||
|
||||
if not sections:
|
||||
return []
|
||||
|
||||
# Calculate total minutes to distribute
|
||||
total_minutes = sum(s.estimated_reading_minutes for s in sections)
|
||||
target_per_meeting = total_minutes / num_meetings
|
||||
|
||||
meetings: list[dict[str, Any]] = []
|
||||
current_meeting: list[int] = []
|
||||
current_minutes = 0
|
||||
|
||||
for section in sections:
|
||||
if current_meeting and current_minutes + section.estimated_reading_minutes > target_per_meeting * 1.4:
|
||||
# Start new meeting if adding this section would overshoot too much
|
||||
if len(meetings) < num_meetings - 1:
|
||||
meetings.append({
|
||||
"meeting_number": len(meetings) + 1,
|
||||
"section_ids": current_meeting,
|
||||
"total_minutes": current_minutes,
|
||||
})
|
||||
current_meeting = []
|
||||
current_minutes = 0
|
||||
|
||||
current_meeting.append(section.id)
|
||||
current_minutes += section.estimated_reading_minutes
|
||||
|
||||
# Add the last meeting
|
||||
if current_meeting:
|
||||
meetings.append({
|
||||
"meeting_number": len(meetings) + 1,
|
||||
"section_ids": current_meeting,
|
||||
"total_minutes": current_minutes,
|
||||
})
|
||||
|
||||
# If we have fewer than num_meetings, we could split the largest one
|
||||
# For now, just return what we have
|
||||
return meetings
|
||||
|
||||
|
||||
def apply_schedule(group_book: GroupBook, num_meetings: int = 4) -> list[ReadingSchedule]:
|
||||
"""Generate and persist a reading schedule."""
|
||||
ReadingSchedule.objects.filter(group_book=group_book).delete()
|
||||
|
||||
recommendations = recommend_meetings(group_book, num_meetings)
|
||||
today = date.today()
|
||||
|
||||
created: list[ReadingSchedule] = []
|
||||
for rec in recommendations:
|
||||
week_date = today + timedelta(weeks=rec["meeting_number"] - 1)
|
||||
schedule = ReadingSchedule.objects.create(
|
||||
group_book=group_book,
|
||||
meeting_number=rec["meeting_number"],
|
||||
week_date=week_date,
|
||||
section_ids=rec["section_ids"],
|
||||
)
|
||||
created.append(schedule)
|
||||
|
||||
return created
|
||||
@@ -0,0 +1,43 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.groups.views import GroupBookViewSet, ReadingGroupViewSet
|
||||
|
||||
# ReadingGroup routes (standard ViewSet)
|
||||
router = DefaultRouter()
|
||||
router.register(r"", ReadingGroupViewSet, basename="group")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
# Nested GroupBook routes under a group
|
||||
path(
|
||||
"<int:group_pk>/books/",
|
||||
GroupBookViewSet.as_view({"get": "list", "post": "create"}),
|
||||
name="group-book-list",
|
||||
),
|
||||
path(
|
||||
"<int:group_pk>/books/<int:pk>/",
|
||||
GroupBookViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
|
||||
name="group-book-detail",
|
||||
),
|
||||
path(
|
||||
"<int:group_pk>/books/<int:pk>/detect-sections/",
|
||||
GroupBookViewSet.as_view({"post": "detect_sections_action"}),
|
||||
name="group-book-detect-sections",
|
||||
),
|
||||
path(
|
||||
"<int:group_pk>/books/<int:pk>/adjust-sections/",
|
||||
GroupBookViewSet.as_view({"post": "adjust_sections"}),
|
||||
name="group-book-adjust-sections",
|
||||
),
|
||||
path(
|
||||
"<int:group_pk>/books/<int:pk>/schedule/",
|
||||
GroupBookViewSet.as_view({"get": "schedule", "post": "schedule", "delete": "schedule"}),
|
||||
name="group-book-schedule",
|
||||
),
|
||||
path(
|
||||
"<int:group_pk>/books/<int:pk>/progress/",
|
||||
GroupBookViewSet.as_view({"get": "progress", "patch": "progress"}),
|
||||
name="group-book-progress",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.books.models import EBook
|
||||
from apps.groups.models import (
|
||||
GroupBook,
|
||||
MemberProgress,
|
||||
ReadingGroup,
|
||||
ReadingGroupMembership,
|
||||
ReadingSchedule,
|
||||
Section,
|
||||
)
|
||||
from apps.groups.serializers import (
|
||||
AddMemberSerializer,
|
||||
GroupBookCreateSerializer,
|
||||
GroupBookDetailSerializer,
|
||||
GroupBookListSerializer,
|
||||
MemberProgressSerializer,
|
||||
ReadingGroupCreateSerializer,
|
||||
ReadingGroupDetailSerializer,
|
||||
ReadingGroupListSerializer,
|
||||
ReadingScheduleSerializer,
|
||||
SectionAdjustSerializer,
|
||||
SectionSerializer,
|
||||
)
|
||||
from apps.groups.services.section_splitting import (
|
||||
apply_schedule,
|
||||
apply_sections,
|
||||
detect_sections,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IsGroupAdmin(permissions.BasePermission):
|
||||
"""Only the group admin can modify group resources."""
|
||||
|
||||
def has_permission(self, request: Request, view: object) -> bool:
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
if view.action in ("list", "retrieve"):
|
||||
return True
|
||||
group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk")
|
||||
if group_id:
|
||||
return ReadingGroup.objects.filter(id=group_id, admin=request.user).exists()
|
||||
return True
|
||||
|
||||
def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool:
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return obj.admin == request.user
|
||||
|
||||
|
||||
class IsGroupMember(permissions.BasePermission):
|
||||
"""Only group members (including admin) can view group content."""
|
||||
|
||||
def has_permission(self, request: Request, view: object) -> bool:
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
group_id = view.kwargs.get("pk") or view.kwargs.get("group_pk")
|
||||
if group_id:
|
||||
return ReadingGroupMembership.objects.filter(
|
||||
group_id=group_id, user=request.user
|
||||
).exists()
|
||||
return True
|
||||
|
||||
|
||||
class ReadingGroupViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated, IsGroupAdmin]
|
||||
queryset = ReadingGroup.objects.prefetch_related("memberships__user")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return ReadingGroupCreateSerializer
|
||||
if self.action == "retrieve":
|
||||
return ReadingGroupDetailSerializer
|
||||
return ReadingGroupListSerializer
|
||||
|
||||
def get_queryset(self) -> QuerySet[ReadingGroup]:
|
||||
user = self.request.user
|
||||
return ReadingGroup.objects.filter(
|
||||
memberships__user=user
|
||||
).prefetch_related("memberships__user").distinct()
|
||||
|
||||
def perform_create(self, serializer: ReadingGroupCreateSerializer) -> ReadingGroup:
|
||||
group = serializer.save(admin=self.request.user)
|
||||
ReadingGroupMembership.objects.create(
|
||||
user=self.request.user, group=group, role=ReadingGroupMembership.Role.ADMIN
|
||||
)
|
||||
return group
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin])
|
||||
def add_member(self, request: Request, pk: int | None = None) -> Response:
|
||||
group = self.get_object()
|
||||
member_serializer = AddMemberSerializer(data=request.data)
|
||||
member_serializer.is_valid(raise_exception=True)
|
||||
|
||||
user_id = member_serializer.validated_data["user_id"]
|
||||
from django.conf import settings
|
||||
User = settings.AUTH_USER_MODEL
|
||||
new_member = get_object_or_404(User, id=user_id)
|
||||
|
||||
if ReadingGroupMembership.objects.filter(group=group, user=new_member).exists():
|
||||
return Response(
|
||||
{"detail": "User is already a member of this group."},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
membership = ReadingGroupMembership.objects.create(
|
||||
user=new_member, group=group, role=ReadingGroupMembership.Role.MEMBER
|
||||
)
|
||||
from apps.groups.serializers import ReadingGroupMembershipSerializer
|
||||
return Response(
|
||||
ReadingGroupMembershipSerializer(membership).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsGroupAdmin])
|
||||
def remove_member(self, request: Request, pk: int | None = None) -> Response:
|
||||
group = self.get_object()
|
||||
member_serializer = AddMemberSerializer(data=request.data)
|
||||
member_serializer.is_valid(raise_exception=True)
|
||||
|
||||
user_id = member_serializer.validated_data["user_id"]
|
||||
if user_id == group.admin_id:
|
||||
return Response(
|
||||
{"detail": "Cannot remove the group admin."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
deleted, _ = ReadingGroupMembership.objects.filter(
|
||||
group=group, user_id=user_id
|
||||
).delete()
|
||||
if not deleted:
|
||||
return Response(
|
||||
{"detail": "User is not a member of this group."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class GroupBookViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated, IsGroupAdmin]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return GroupBookCreateSerializer
|
||||
if self.action == "retrieve":
|
||||
return GroupBookDetailSerializer
|
||||
return GroupBookListSerializer
|
||||
|
||||
def get_queryset(self) -> QuerySet[GroupBook]:
|
||||
group_id = self.kwargs.get("group_pk")
|
||||
return GroupBook.objects.filter(
|
||||
group_id=group_id
|
||||
).select_related("ebook", "ebook__user", "uploaded_by").prefetch_related(
|
||||
"sections", "schedules"
|
||||
)
|
||||
|
||||
def perform_create(self, serializer: GroupBookCreateSerializer) -> GroupBook:
|
||||
group = get_object_or_404(ReadingGroup, id=self.kwargs["group_pk"])
|
||||
ebook = get_object_or_404(EBook, id=serializer.validated_data["ebook_id"])
|
||||
title = serializer.validated_data.get("title") or ebook.title
|
||||
|
||||
# Mark existing active books as replaced
|
||||
GroupBook.objects.filter(group=group, status=GroupBook.Status.ACTIVE).update(
|
||||
status=GroupBook.Status.REPLACED
|
||||
)
|
||||
|
||||
group_book = GroupBook.objects.create(
|
||||
group=group,
|
||||
ebook=ebook,
|
||||
uploaded_by=self.request.user,
|
||||
title=title,
|
||||
status=GroupBook.Status.ACTIVE,
|
||||
)
|
||||
|
||||
# Auto-detect sections
|
||||
try:
|
||||
apply_sections(group_book)
|
||||
except Exception:
|
||||
logger.exception("Auto-section detection failed for group_book %s", group_book.id)
|
||||
|
||||
# Auto-generate schedule
|
||||
try:
|
||||
apply_schedule(group_book)
|
||||
except Exception:
|
||||
logger.exception("Schedule generation failed for group_book %s", group_book.id)
|
||||
|
||||
return group_book
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="detect-sections")
|
||||
def detect_sections_action(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
|
||||
group_book = self.get_object()
|
||||
try:
|
||||
sections = apply_sections(group_book)
|
||||
except Exception as exc:
|
||||
logger.exception("Section detection failed for group_book %s", group_book.id)
|
||||
return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return Response(SectionSerializer(sections, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="adjust-sections")
|
||||
def adjust_sections(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
|
||||
group_book = self.get_object()
|
||||
adjust_serializer = SectionAdjustSerializer(data=request.data)
|
||||
adjust_serializer.is_valid(raise_exception=True)
|
||||
|
||||
operation = adjust_serializer.validated_data["operation"]
|
||||
section_ids: list[int] = adjust_serializer.validated_data["section_ids"]
|
||||
|
||||
if operation == "merge":
|
||||
if len(section_ids) < 2:
|
||||
return Response(
|
||||
{"error": "At least 2 section IDs required for merge."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
sections = list(
|
||||
Section.objects.filter(id__in=section_ids, group_book=group_book).order_by("order")
|
||||
)
|
||||
if len(sections) < 2:
|
||||
return Response(
|
||||
{"error": "Not enough valid sections found."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
merged_title = " — ".join(s.title for s in sections)
|
||||
merged_minutes = sum(s.estimated_reading_minutes for s in sections)
|
||||
merged_start = sections[0].start_chapter_index
|
||||
merged_end = sections[-1].end_chapter_index
|
||||
merged_order = sections[0].order
|
||||
|
||||
# Delete old sections
|
||||
Section.objects.filter(id__in=section_ids, group_book=group_book).delete()
|
||||
|
||||
# Create merged section
|
||||
merged = Section.objects.create(
|
||||
group_book=group_book,
|
||||
title=merged_title,
|
||||
order=merged_order,
|
||||
start_chapter_index=merged_start,
|
||||
end_chapter_index=merged_end,
|
||||
estimated_reading_minutes=merged_minutes,
|
||||
)
|
||||
# Reorder remaining sections
|
||||
_renumber_sections(group_book)
|
||||
return Response(SectionSerializer(merged).data)
|
||||
|
||||
if operation == "split":
|
||||
if len(section_ids) != 1:
|
||||
return Response(
|
||||
{"error": "Exactly 1 section ID required for split."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
split_at = adjust_serializer.validated_data.get("split_at", 2)
|
||||
section = get_object_or_404(Section, id=section_ids[0], group_book=group_book)
|
||||
chapters = section.end_chapter_index - section.start_chapter_index
|
||||
if chapters < split_at:
|
||||
return Response(
|
||||
{"error": f"Section only has {chapters} chapters, cannot split into {split_at}."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Delete original
|
||||
orig_order = section.order
|
||||
orig_title = section.title
|
||||
section.delete()
|
||||
|
||||
# Create split sections
|
||||
chunk_size = max(1, chapters // split_at)
|
||||
new_sections: list[Section] = []
|
||||
for i in range(split_at):
|
||||
start_idx = section.start_chapter_index + i * chunk_size
|
||||
end_idx = start_idx + chunk_size if i < split_at - 1 else section.end_chapter_index
|
||||
minutes_each = max(1, section.estimated_reading_minutes // split_at)
|
||||
new_sec = Section.objects.create(
|
||||
group_book=group_book,
|
||||
title=f"{orig_title} (Part {i + 1})",
|
||||
order=orig_order + i,
|
||||
start_chapter_index=start_idx,
|
||||
end_chapter_index=end_idx,
|
||||
estimated_reading_minutes=minutes_each,
|
||||
)
|
||||
new_sections.append(new_sec)
|
||||
|
||||
_renumber_sections(group_book)
|
||||
return Response(SectionSerializer(new_sections, many=True).data)
|
||||
|
||||
return Response({"error": "Invalid operation."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"], url_path="schedule")
|
||||
def schedule(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
|
||||
group_book = self.get_object()
|
||||
|
||||
if request.method == "GET":
|
||||
schedules = ReadingSchedule.objects.filter(group_book=group_book).order_by("meeting_number")
|
||||
return Response(ReadingScheduleSerializer(schedules, many=True).data)
|
||||
|
||||
if request.method == "POST":
|
||||
ReadingSchedule.objects.filter(group_book=group_book).delete()
|
||||
num_meetings = int(request.data.get("num_meetings", 4))
|
||||
try:
|
||||
schedules = apply_schedule(group_book, num_meetings=num_meetings)
|
||||
except Exception as exc:
|
||||
logger.exception("Schedule generation failed for group_book %s", group_book.id)
|
||||
return Response({"error": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return Response(
|
||||
ReadingScheduleSerializer(schedules, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
if request.method == "DELETE":
|
||||
ReadingSchedule.objects.filter(group_book=group_book).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["get", "patch"], url_path="progress")
|
||||
def progress(self, request: Request, group_pk: int | None = None, pk: int | None = None) -> Response:
|
||||
group_book = self.get_object()
|
||||
|
||||
if request.method == "GET":
|
||||
progress_records = MemberProgress.objects.filter(
|
||||
group_book=group_book
|
||||
).select_related("user", "current_section")
|
||||
return Response(MemberProgressSerializer(progress_records, many=True).data)
|
||||
|
||||
# PATCH: update own progress
|
||||
progress_obj, _created = MemberProgress.objects.get_or_create(
|
||||
user=request.user, group_book=group_book,
|
||||
)
|
||||
serializer = MemberProgressSerializer(progress_obj, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
def _renumber_sections(group_book: GroupBook) -> None:
|
||||
"""Re-number sections sequentially by their current order."""
|
||||
sections = list(Section.objects.filter(group_book=group_book).order_by("order"))
|
||||
for idx, section in enumerate(sections):
|
||||
if section.order != idx + 1:
|
||||
section.order = idx + 1
|
||||
section.save(update_fields=["order"])
|
||||
Reference in New Issue
Block a user