Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3b709ba5 | ||
|
|
26c5f6f06b | ||
|
|
22ded87250 | ||
|
|
84c0497f21 | ||
|
|
724eae1142 | ||
|
|
654cfec147 | ||
|
|
f989b144cf | ||
|
|
f091386974 | ||
|
|
d9c66e68a6 | ||
|
|
054e7689bd | ||
|
|
6b4c0c43f8 | ||
|
|
730c748f5f | ||
|
|
7cdc3d0969 | ||
|
|
a7864bf5cd | ||
|
|
edac7cb08a | ||
|
|
74fdae61db | ||
|
|
3f626259e8 | ||
|
|
ade810a013 | ||
|
|
6a223d7237 | ||
|
|
656d20879f | ||
|
|
2eeb3fb7d6 | ||
|
|
658f77a746 | ||
|
|
b29ea8211c | ||
|
|
fa82fab44a | ||
|
|
670101b61e | ||
|
|
332b539880 | ||
|
|
f84a593c5d | ||
|
|
3b5b301e42 |
@@ -0,0 +1,10 @@
|
||||
POSTGRES_DB=postres
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root
|
||||
|
||||
DJANGO_SECRET_KEY=changeme
|
||||
DJANGO_DEBUG=True
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
VITE_API_URL=http://localhost:8000/api
|
||||
+37
-31
@@ -1,34 +1,40 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
*.egg
|
||||
.venv/
|
||||
venv/
|
||||
node_modules
|
||||
dist
|
||||
frontend/dist
|
||||
frontend/dist/assets
|
||||
frontend/dist/assets/index.html
|
||||
frontend/dist/assets/index.html.gz
|
||||
frontend/dist/assets/index.html.br
|
||||
frontend/dist/assets/index.html.brotli
|
||||
frontend/dist/assets/index.html.gzip
|
||||
frontend/dist/assets/index.html.br
|
||||
frontend/dist/assets/index.html.brotli
|
||||
frontend/dist/assets/index.html.gzip
|
||||
frontend/dist/assets/index.html.br
|
||||
frontend/dist/assets/index.html.brotli
|
||||
frontend/dist/assets/index.html.gzip
|
||||
frontend/dist/assets/index.html.br
|
||||
frontend/dist/assets/index.html.brotli
|
||||
frontend/dist/assets/index.html.gzip
|
||||
frontend/dist/assets/index.html.br
|
||||
frontend/dist/assets/index.html.brotli
|
||||
frontend/dist/assets/index.html.gzip
|
||||
mobile/dist
|
||||
backend/media
|
||||
backend/staticfiles
|
||||
backend/media
|
||||
backend/staticfiles
|
||||
.pycache__
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.yarn/
|
||||
yarn-error.log
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
*.env
|
||||
*.DS_Store
|
||||
*.vscode
|
||||
*.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Build artifacts (generated)
|
||||
frontend/dist/
|
||||
@@ -0,0 +1,105 @@
|
||||
# AGENTS.md — Cloud Reader monorepo
|
||||
|
||||
Guidance for AI agents and contributors working in this repository.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
cloud-reader/
|
||||
├── backend/ # Django REST API (canonical backend)
|
||||
├── frontend/ # React + Vite + TypeScript (web)
|
||||
├── mobile/ # Expo React Native app
|
||||
├── packages/shared/ # @cloud-reader/shared types & utils
|
||||
├── docs/ # Feature specs and architecture notes
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
- **Backend** is the source of truth for API contracts, auth, and persistence.
|
||||
- **Frontend** and **mobile** consume the same REST API; share domain types via `@cloud-reader/shared` where practical.
|
||||
- Do not reintroduce removed `api/` or `web/` directories.
|
||||
|
||||
## Documentation conventions
|
||||
|
||||
| Location | Purpose |
|
||||
|----------|---------|
|
||||
| `docs/NNN-*.md` | Cross-cutting or product specs (e.g. `001-customizable-mobile-reading-experience.md`) |
|
||||
| `docs/backend/NNN-*.md` | Backend feature specs; use next sequential number (currently `010`) |
|
||||
| `docs/mobile/NNN-*.md` | Mobile (Expo) feature specs; use next sequential number (currently `010`) |
|
||||
| `docs/frontend/*.md` | Frontend-specific specs |
|
||||
|
||||
When adding a **major backend feature**:
|
||||
|
||||
1. Implement in `backend/apps/<app>/`.
|
||||
2. Add or update a numbered spec under `docs/backend/`.
|
||||
3. Include: objective, API contracts, models/services touched, env vars, and verification steps.
|
||||
|
||||
Do not edit plan files in `.cursor/plans/` unless explicitly asked.
|
||||
|
||||
## Backend (Django)
|
||||
|
||||
- **Python:** 3.12+, managed with `uv` (`backend/pyproject.toml`, `backend/uv.lock`).
|
||||
- **Settings:** `backend/config/settings.py` (pydantic-settings) + `backend/config/django.py`.
|
||||
- **Apps:** `users`, `books`, `annotations`, `reader`.
|
||||
- **Auth:** JWT via `djangorestframework-simplejwt`; register/login at `/api/auth/`.
|
||||
- **Default permission:** `IsAuthenticated` — public endpoints must set `AllowAny` explicitly.
|
||||
- **URL routing:** Single `DefaultRouter` in `books/urls.py`; register specific prefixes (e.g. `ebooks`) **before** the empty `""` book route to avoid `{pk}` shadowing.
|
||||
- **Services:** Put non-trivial logic in `apps/<app>/services/` (not in views/serializers).
|
||||
- **Migrations:** Run after model changes; prefer reusing existing JSON fields (e.g. `EBook.metadata_json`) before new columns.
|
||||
- **Tests:** Only add when requested or when they cover non-obvious behavior.
|
||||
|
||||
### Books domain
|
||||
|
||||
- **`Book`:** Catalog/discovery entity (genres, reading status).
|
||||
- **`EBook`:** Per-user uploaded file (EPUB/PDF); primary import path via `POST /api/books/ebooks/`.
|
||||
- **`metadata_json` on EBook:** External metadata (Open Library, etc.).
|
||||
- **`cover_image` on EBook:** Stored file; library UI reads it from list/detail serializers.
|
||||
|
||||
## Frontend (web)
|
||||
|
||||
- **Entry:** `frontend/src/main.tsx` mounts `App.tsx` (React Router + auth).
|
||||
- **API client:** `frontend/src/api/client.ts` (axios, JWT refresh, base URL `/api`).
|
||||
- **Auth routes:** `/auth` (not `/login`).
|
||||
- **Imports:** Upload via `booksApi.uploadEBook`; library lists user `EBook`s.
|
||||
- **Styling:** Mix of inline styles and CSS modules; match surrounding patterns.
|
||||
- Do not add tests or new `.md` files unless requested.
|
||||
|
||||
## Mobile (Expo)
|
||||
|
||||
- Uses `@cloud-reader/shared` and mirrors web API patterns.
|
||||
- Token storage: AsyncStorage; base URL from `EXPO_PUBLIC_API_URL`.
|
||||
|
||||
## Shared package
|
||||
|
||||
- `packages/shared/src/types.ts` — domain types for web/mobile.
|
||||
- `packages/shared/src/utils.ts` — API endpoint constants, helpers.
|
||||
- Keep camelCase in TS; backend JSON may use snake_case (DRF default).
|
||||
|
||||
## Code change principles
|
||||
|
||||
1. **Minimize scope** — smallest correct diff; no drive-by refactors.
|
||||
2. **Match conventions** — read neighboring code before adding new patterns.
|
||||
3. **No over-engineering** — no extra abstractions for one-off use.
|
||||
4. **Comments** — only for non-obvious business logic.
|
||||
5. **Imports** — remove unused imports; delete dead code after refactors.
|
||||
6. **Secrets** — never commit `.env`; document vars in `.env.example` only.
|
||||
|
||||
## Git & PRs
|
||||
|
||||
- Commit only when the user asks.
|
||||
- Do not force-push `main`/`master`.
|
||||
- Use `gh` for GitHub PRs when requested.
|
||||
|
||||
## Postman
|
||||
|
||||
- Use camelCase operation names (`listSomething`, `createSomething`).
|
||||
- Ask which env vars the user uses; output JSON to copy, not a new file.
|
||||
|
||||
## Commands (run locally when needed)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && uv sync && uv run python manage.py migrate && uv run python manage.py runserver
|
||||
|
||||
# Frontend
|
||||
cd frontend && yarn install && yarn dev
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# Cloud Reader
|
||||
|
||||
A full-stack e-book reader application with cross-device sync. Upload EPUB/PDF files, track reading progress, bookmark passages, take notes, and customize your reading experience.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
cloud-reader/
|
||||
├── backend/ # Django REST API (canonical backend)
|
||||
│ ├── config/ # Django project settings
|
||||
│ ├── apps/
|
||||
│ │ ├── users/ # User auth (JWT)
|
||||
│ │ ├── books/ # Books, e-books, reading progress, settings
|
||||
│ │ └── annotations/ # Bookmarks and notes
|
||||
│ ├── manage.py
|
||||
│ └── requirements.txt
|
||||
├── frontend/ # React + Vite + TypeScript (web frontend)
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client (axios with JWT refresh)
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── context/ # Auth and annotations context
|
||||
│ │ ├── hooks/ # Custom hooks
|
||||
│ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings)
|
||||
│ │ └── types/ # TypeScript type definitions
|
||||
│ └── package.json
|
||||
├── mobile/ # Expo React Native app (mobile frontend)
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client (axios with JWT refresh via AsyncStorage)
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ ├── context/ # Auth context
|
||||
│ │ ├── hooks/ # Custom hooks
|
||||
│ │ ├── navigation/ # React Navigation (Auth stack + Main tabs)
|
||||
│ │ ├── screens/ # Screen-level components (Login, Library, etc.)
|
||||
│ │ └── types/ # Mobile-specific types
|
||||
│ ├── App.tsx
|
||||
│ └── app.json
|
||||
├── packages/
|
||||
│ └── shared/ # @cloud-reader/shared — domain types & utilities
|
||||
│ └── src/
|
||||
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note, etc.)
|
||||
│ └── utils.ts # Date formatting, validation, API endpoint constants
|
||||
├── package.json # Root — yarn workspaces config
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (recommended)
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
- **Frontend:** http://localhost:5173
|
||||
- **Backend API:** http://localhost:8000/api/
|
||||
|
||||
### Backend (standalone)
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
### Frontend (standalone)
|
||||
```bash
|
||||
cd frontend
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### Mobile (Expo)
|
||||
```bash
|
||||
# From monorepo root — installs all workspaces including mobile
|
||||
yarn install
|
||||
|
||||
# Start Expo dev server
|
||||
yarn workspace @cloud-reader/mobile start
|
||||
|
||||
# Or cd into mobile and run directly
|
||||
cd mobile
|
||||
npx expo start
|
||||
```
|
||||
|
||||
> The mobile app requires the backend to be running. Set `EXPO_PUBLIC_API_URL` environment variable in your shell or `.env` file to point to the backend (defaults to `http://10.0.2.2:8000` for Android emulator).
|
||||
|
||||
## Migration Notes
|
||||
Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure.
|
||||
- `backend/` kept as canonical; `api/` features (e-book uploads, reading progress, reading settings) merged in.
|
||||
- `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in.
|
||||
- `api/` and `web/` directories removed.
|
||||
- `mobile/` added as Expo React Native app with shared `@cloud-reader/shared` package.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Backend environment (example – never commit real secrets)
|
||||
DJANGO_SECRET_KEY=django-insecure-change-me-in-production
|
||||
DJANGO_DEBUG=True
|
||||
DB_NAME=cloud_reader
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
# Open Library metadata enrichment (optional)
|
||||
OPENLIBRARY_ENABLED=true
|
||||
OPENLIBRARY_PREFERRED_LANG=es
|
||||
OPENLIBRARY_FALLBACK_LANG=en
|
||||
OPENLIBRARY_TIMEOUT_SECONDS=10
|
||||
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS=15
|
||||
OPENLIBRARY_USER_AGENT=CloudReader/1.0
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev gcc && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . ./
|
||||
|
||||
RUN mkdir -p media
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.annotations.models import Bookmark, Note
|
||||
|
||||
|
||||
@admin.register(Bookmark)
|
||||
class BookmarkAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"user",
|
||||
"ebook",
|
||||
"chapter_index",
|
||||
"chapter_title",
|
||||
"page",
|
||||
"highlight_color",
|
||||
"created_at",
|
||||
)
|
||||
list_select_related = ("user", "ebook")
|
||||
search_fields = ("user__email", "ebook__title", "location_text", "content")
|
||||
list_filter = ("created_at",)
|
||||
|
||||
|
||||
@admin.register(Note)
|
||||
class NoteAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "book", "page", "created_at", "updated_at")
|
||||
list_select_related = ("user", "book")
|
||||
search_fields = ("user__email", "book__title", "content", "location_text")
|
||||
list_filter = ("created_at",)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnnotationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.annotations"
|
||||
label = "annotations"
|
||||
@@ -0,0 +1,57 @@
|
||||
# Generated by Django 5.1.7 on 2026-06-03 22:27
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('books', '0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Note',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('page', models.PositiveIntegerField()),
|
||||
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text this note refers to')),
|
||||
('content', models.TextField(help_text='The note body content')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='books.book')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Note',
|
||||
'verbose_name_plural': 'Notes',
|
||||
'db_table': 'annotations_note',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Bookmark',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('page', models.PositiveIntegerField()),
|
||||
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text at this location')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.book')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Bookmark',
|
||||
'verbose_name_plural': 'Bookmarks',
|
||||
'db_table': 'annotations_bookmark',
|
||||
'ordering': ['-created_at'],
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'book', 'page'), name='uq_bookmark_user_book_page')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def clear_legacy_bookmarks(apps, schema_editor):
|
||||
Bookmark = apps.get_model("annotations", "Bookmark")
|
||||
Bookmark.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0003_readingprogress_epub_location"),
|
||||
("annotations", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(clear_legacy_bookmarks, migrations.RunPython.noop),
|
||||
migrations.RemoveConstraint(
|
||||
model_name="bookmark",
|
||||
name="uq_bookmark_user_book_page",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="bookmark",
|
||||
name="book",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="ebook",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="bookmarks",
|
||||
to="books.ebook",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="epub_cfi",
|
||||
field=models.CharField(db_index=True, default="", max_length=2048),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="chapter_index",
|
||||
field=models.PositiveIntegerField(db_index=True, default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="chapter_title",
|
||||
field=models.CharField(blank=True, default="", max_length=512),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="content",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="bookmark",
|
||||
name="page",
|
||||
field=models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Legacy/display page; derived from chapter_index + 1",
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="bookmark",
|
||||
options={
|
||||
"ordering": ["chapter_index", "epub_cfi"],
|
||||
"verbose_name": "Bookmark",
|
||||
"verbose_name_plural": "Bookmarks",
|
||||
},
|
||||
),
|
||||
# ebook was added nullable for SQLite; enforce NOT NULL via AlterField
|
||||
migrations.AlterField(
|
||||
model_name="bookmark",
|
||||
name="ebook",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="bookmarks",
|
||||
to="books.ebook",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="bookmark",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user", "ebook", "epub_cfi"),
|
||||
name="uq_bookmark_user_ebook_cfi",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("annotations", "0002_bookmark_ebook_epub_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="highlight_color",
|
||||
field=models.CharField(default="#fde047", max_length=7),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.7 on 2026-06-04 03:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('annotations', '0003_bookmark_highlight_color'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, default='', help_text='Optional user thought; empty means bookmark-only'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='highlight_color',
|
||||
field=models.CharField(default='#fde047', help_text='Hex color for in-book passage highlight (e.g. #fde047)', max_length=7),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Bookmark(models.Model):
|
||||
"""A saved passage anchor in an uploaded ebook (EPUB CFI + optional thought)."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="bookmarks",
|
||||
db_index=True,
|
||||
)
|
||||
ebook = models.ForeignKey(
|
||||
"books.EBook",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="bookmarks",
|
||||
db_index=True,
|
||||
)
|
||||
epub_cfi = models.CharField(max_length=2048, db_index=True)
|
||||
chapter_index = models.PositiveIntegerField(default=0, db_index=True)
|
||||
chapter_title = models.CharField(max_length=512, blank=True, default="")
|
||||
page = models.PositiveIntegerField(
|
||||
default=1,
|
||||
help_text="Legacy/display page; derived from chapter_index + 1",
|
||||
)
|
||||
location_text = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="The selected passage text at this location",
|
||||
)
|
||||
content = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Optional user thought; empty means bookmark-only",
|
||||
)
|
||||
highlight_color = models.CharField(
|
||||
max_length=7,
|
||||
default="#fde047",
|
||||
help_text="Hex color for in-book passage highlight (e.g. #fde047)",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "annotations_bookmark"
|
||||
verbose_name = "Bookmark"
|
||||
verbose_name_plural = "Bookmarks"
|
||||
ordering = ["chapter_index", "epub_cfi"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "ebook", "epub_cfi"],
|
||||
name="uq_bookmark_user_ebook_cfi",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} @ {self.ebook} ch.{self.chapter_index}"
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
"""Legacy note model; new UX uses Bookmark.content instead."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notes",
|
||||
db_index=True,
|
||||
)
|
||||
book = models.ForeignKey(
|
||||
"books.Book",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notes",
|
||||
db_index=True,
|
||||
)
|
||||
page = models.PositiveIntegerField()
|
||||
location_text = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="The selected passage text this note refers to",
|
||||
)
|
||||
content = models.TextField(
|
||||
help_text="The note body content"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "annotations_note"
|
||||
verbose_name = "Note"
|
||||
verbose_name_plural = "Notes"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
preview = self.content[:50]
|
||||
return f"{self.user} @ {self.book} p.{self.page}: {preview}"
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class IsOwner(permissions.BasePermission):
|
||||
"""Grant access only if the requesting user owns the object."""
|
||||
|
||||
def has_object_permission(self, request, view, obj) -> bool:
|
||||
return obj.user == request.user
|
||||
@@ -0,0 +1,124 @@
|
||||
import re
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.annotations.models import Bookmark, Note
|
||||
|
||||
_HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
def validate_highlight_color(value: str) -> str:
|
||||
stripped = (value or "").strip()
|
||||
if not _HEX_COLOR_RE.match(stripped):
|
||||
raise serializers.ValidationError("highlight_color must be a hex color like #fde047.")
|
||||
return stripped.lower()
|
||||
|
||||
|
||||
class BookmarkSerializer(serializers.ModelSerializer):
|
||||
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"id",
|
||||
"ebook",
|
||||
"ebook_title",
|
||||
"epub_cfi",
|
||||
"chapter_index",
|
||||
"chapter_title",
|
||||
"page",
|
||||
"location_text",
|
||||
"content",
|
||||
"highlight_color",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "ebook_title", "page"]
|
||||
|
||||
|
||||
class BookmarkCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
"ebook",
|
||||
"epub_cfi",
|
||||
"chapter_index",
|
||||
"chapter_title",
|
||||
"location_text",
|
||||
"content",
|
||||
"highlight_color",
|
||||
]
|
||||
|
||||
def validate_highlight_color(self, value: str) -> str:
|
||||
return validate_highlight_color(value)
|
||||
|
||||
def validate_epub_cfi(self, value: str) -> str:
|
||||
stripped = (value or "").strip()
|
||||
if not stripped:
|
||||
raise serializers.ValidationError("epub_cfi is required.")
|
||||
return stripped
|
||||
|
||||
def validate_chapter_index(self, value: int) -> int:
|
||||
if value < 0:
|
||||
raise serializers.ValidationError("chapter_index must be non-negative.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
user = self.context["request"].user
|
||||
ebook = attrs["ebook"]
|
||||
epub_cfi = attrs["epub_cfi"]
|
||||
if Bookmark.objects.filter(user=user, ebook=ebook, epub_cfi=epub_cfi).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"epub_cfi": "A marker already exists for this passage."}
|
||||
)
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["user"] = self.context["request"].user
|
||||
validated_data["page"] = validated_data.get("chapter_index", 0) + 1
|
||||
validated_data["content"] = (validated_data.get("content") or "").strip()
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class NoteSerializer(serializers.ModelSerializer):
|
||||
book_title = serializers.CharField(source="book.title", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = [
|
||||
"id",
|
||||
"book",
|
||||
"book_title",
|
||||
"page",
|
||||
"location_text",
|
||||
"content",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "book_title"]
|
||||
|
||||
def validate_page(self, value: int) -> int:
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Page must be a positive integer.")
|
||||
return value
|
||||
|
||||
|
||||
class NoteCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = ["book", "page", "location_text", "content"]
|
||||
|
||||
def validate_page(self, value: int) -> int:
|
||||
if value < 1:
|
||||
raise serializers.ValidationError("Page must be a positive integer.")
|
||||
return value
|
||||
|
||||
def validate_content(self, value: str) -> str:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise serializers.ValidationError("Note content cannot be empty.")
|
||||
return stripped
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["user"] = self.context["request"].user
|
||||
return super().create(validated_data)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Tests for the annotations app – Bookmarks & Notes API."""
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.annotations.models import Bookmark, Note
|
||||
from apps.books.models import Book, EBook
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client() -> APIClient:
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user() -> User:
|
||||
return User.objects.create_user(
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_user() -> User:
|
||||
return User.objects.create_user(
|
||||
username="other",
|
||||
email="other@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(api_client: APIClient, user: User) -> APIClient:
|
||||
api_client.force_authenticate(user=user)
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def book() -> Book:
|
||||
return Book.objects.create(
|
||||
title="Test Book",
|
||||
author="Test Author",
|
||||
total_pages=300,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ebook(user: User) -> EBook:
|
||||
return EBook.objects.create(
|
||||
user=user,
|
||||
title="Test Ebook",
|
||||
author="Test Author",
|
||||
format="epub",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bookmark(auth_client, user: User, ebook: EBook) -> Bookmark:
|
||||
return Bookmark.objects.create(
|
||||
user=user,
|
||||
ebook=ebook,
|
||||
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:10)",
|
||||
chapter_index=2,
|
||||
chapter_title="Chapter 3",
|
||||
page=3,
|
||||
location_text="important passage",
|
||||
content="",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def note(auth_client, user: User, book: Book) -> Note:
|
||||
return Note.objects.create(
|
||||
user=user,
|
||||
book=book,
|
||||
page=15,
|
||||
location_text="highlighted section",
|
||||
content="This is my note about this section.",
|
||||
)
|
||||
|
||||
|
||||
class TestBookmarkList:
|
||||
url = reverse("bookmark-list")
|
||||
|
||||
def test_list_requires_auth(self, api_client: APIClient):
|
||||
response = api_client.get(self.url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_list_returns_user_bookmarks_only(
|
||||
self, auth_client: APIClient, user: User, other_user: User, ebook: EBook
|
||||
):
|
||||
Bookmark.objects.create(
|
||||
user=user,
|
||||
ebook=ebook,
|
||||
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:5)",
|
||||
chapter_index=0,
|
||||
page=1,
|
||||
)
|
||||
other_ebook = EBook.objects.create(user=other_user, title="Other", format="epub")
|
||||
Bookmark.objects.create(
|
||||
user=other_user,
|
||||
ebook=other_ebook,
|
||||
epub_cfi="epubcfi(/6/4!/4/2,/2:0,/2:5)",
|
||||
chapter_index=0,
|
||||
page=1,
|
||||
)
|
||||
response = auth_client.get(self.url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.data["results"]
|
||||
assert len(results) == 1
|
||||
|
||||
def test_filter_by_ebook(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
||||
response = auth_client.get(self.url, {"ebook": str(ebook.id)})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 1
|
||||
|
||||
|
||||
class TestBookmarkCreate:
|
||||
url = reverse("bookmark-list")
|
||||
|
||||
def test_create_marker(self, auth_client: APIClient, ebook: EBook):
|
||||
data = {
|
||||
"ebook": ebook.id,
|
||||
"epub_cfi": "epubcfi(/6/4!/4/2,/1:0,/1:20)",
|
||||
"chapter_index": 1,
|
||||
"chapter_title": "Chapter 2",
|
||||
"location_text": "Selected text",
|
||||
"content": "My thought",
|
||||
}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["content"] == "My thought"
|
||||
assert response.data["ebook"] == ebook.id
|
||||
|
||||
def test_create_bookmark_only_empty_content(self, auth_client: APIClient, ebook: EBook):
|
||||
data = {
|
||||
"ebook": ebook.id,
|
||||
"epub_cfi": "epubcfi(/6/4!/4/2,/3:0,/3:8)",
|
||||
"chapter_index": 0,
|
||||
"location_text": "Quote only",
|
||||
"content": "",
|
||||
}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["content"] == ""
|
||||
|
||||
def test_duplicate_cfi_rejected(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
||||
data = {
|
||||
"ebook": ebook.id,
|
||||
"epub_cfi": bookmark.epub_cfi,
|
||||
"chapter_index": 0,
|
||||
"location_text": "dup",
|
||||
}
|
||||
response = auth_client.post(self.url, data, format="json")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
class TestBookmarkDetail:
|
||||
def test_delete_bookmark(self, auth_client: APIClient, bookmark: Bookmark):
|
||||
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||||
response = auth_client.delete(url)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.annotations.views import BookmarkViewSet, NoteViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
|
||||
router.register(r"notes", NoteViewSet, basename="note")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.annotations.models import Bookmark, Note
|
||||
from apps.annotations.permissions import IsOwner
|
||||
from apps.annotations.serializers import (
|
||||
BookmarkCreateSerializer,
|
||||
BookmarkSerializer,
|
||||
NoteCreateSerializer,
|
||||
NoteSerializer,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkViewSet(viewsets.ModelViewSet):
|
||||
"""CRUD for user ebook markers (passage anchors + optional thoughts)."""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsOwner]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["ebook"]
|
||||
search_fields = ["location_text", "content", "chapter_title"]
|
||||
ordering_fields = ["chapter_index", "epub_cfi", "created_at"]
|
||||
ordering = ["chapter_index", "epub_cfi"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return BookmarkCreateSerializer
|
||||
return BookmarkSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Bookmark.objects.filter(user=self.request.user).select_related("ebook")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=["delete"], url_path="batch-delete")
|
||||
def batch_delete(self, request):
|
||||
ids = request.data.get("ids", [])
|
||||
if not ids:
|
||||
return Response(
|
||||
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
deleted, _ = Bookmark.objects.filter(
|
||||
id__in=ids, user=request.user
|
||||
).delete()
|
||||
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class NoteViewSet(viewsets.ModelViewSet):
|
||||
"""Legacy notes API (catalog Book FK)."""
|
||||
|
||||
permission_classes = [IsAuthenticated, IsOwner]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["book"]
|
||||
search_fields = ["content", "location_text"]
|
||||
ordering_fields = ["created_at", "page"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return NoteCreateSerializer
|
||||
return NoteSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return Note.objects.filter(user=self.request.user).select_related("book")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=["delete"], url_path="batch-delete")
|
||||
def batch_delete(self, request):
|
||||
ids = request.data.get("ids", [])
|
||||
if not ids:
|
||||
return Response(
|
||||
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
deleted, _ = Note.objects.filter(
|
||||
id__in=ids, user=request.user
|
||||
).delete()
|
||||
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.books.models import Book
|
||||
|
||||
|
||||
@admin.register(Book)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "author", "total_pages", "created_at")
|
||||
search_fields = ("title", "author")
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BooksConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.books"
|
||||
label = "books"
|
||||
@@ -0,0 +1,96 @@
|
||||
# Generated by Django 5.1.7 on 2026-05-26 03:33
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Book',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(db_index=True, max_length=512)),
|
||||
('author', models.CharField(blank=True, db_index=True, default='', max_length=256)),
|
||||
('genre', models.CharField(blank=True, db_index=True, default='', max_length=128)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('reading_status', models.CharField(choices=[('want_to_read', 'Want to Read'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], db_index=True, default='want_to_read', max_length=20)),
|
||||
('total_pages', models.PositiveIntegerField(default=0)),
|
||||
('cover_image', models.URLField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Book',
|
||||
'verbose_name_plural': 'Books',
|
||||
'db_table': 'books_book',
|
||||
'ordering': ['title'],
|
||||
'indexes': [models.Index(fields=['title', 'author', 'genre'], name='books_book_title_9fddc2_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EBook',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=512)),
|
||||
('author', models.CharField(blank=True, default='', max_length=256)),
|
||||
('file', models.FileField(upload_to='ebooks/%Y/%m/%d/')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='ebook_covers/%Y/%m/%d/')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebooks', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'E-Book',
|
||||
'verbose_name_plural': 'E-Books',
|
||||
'db_table': 'books_ebook',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReadingProgress',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('current_position', models.FloatField(default=0.0)),
|
||||
('last_page', models.IntegerField(default=0)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('ebook', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to='books.ebook')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'reading progress',
|
||||
'db_table': 'books_reading_progress',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReadingSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('font_size', models.IntegerField(default=18)),
|
||||
('font_style', models.CharField(choices=[('sans-serif', 'Sans Serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='sans-serif', max_length=20)),
|
||||
('background_color', models.CharField(choices=[('#ffffff', 'White'), ('#f4e4c1', 'Sepia'), ('#1a1a2e', 'Dark'), ('#c7edcc', 'Green')], default='#ffffff', max_length=7)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_settings', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'reading settings',
|
||||
'db_table': 'books_reading_settings',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='ebook',
|
||||
index=models.Index(fields=['user', '-created_at'], name='books_ebook_user_id_0b6bdb_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='readingprogress',
|
||||
unique_together={('user', 'ebook')},
|
||||
),
|
||||
]
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Generated by Django 5.1.7 on 2026-06-03 22:21
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ebook',
|
||||
name='file_size',
|
||||
field=models.BigIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ebook',
|
||||
name='format',
|
||||
field=models.CharField(blank=True, default='', editable=False, max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ebook',
|
||||
name='metadata_json',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ebook',
|
||||
name='page_count',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='readingprogress',
|
||||
name='device_id',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='readingprogress',
|
||||
name='device_name',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='readingprogress',
|
||||
name='version',
|
||||
field=models.PositiveIntegerField(default=1),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ebook',
|
||||
name='author',
|
||||
field=models.CharField(blank=True, db_index=True, default='', max_length=256),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ebook',
|
||||
name='title',
|
||||
field=models.CharField(db_index=True, max_length=512),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BookChapter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=512)),
|
||||
('index', models.IntegerField(default=0)),
|
||||
('href', models.CharField(blank=True, default='', max_length=1024)),
|
||||
('children', models.JSONField(blank=True, default=list)),
|
||||
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='books.ebook')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Book Chapter',
|
||||
'verbose_name_plural': 'Book Chapters',
|
||||
'db_table': 'books_book_chapter',
|
||||
'ordering': ['index'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DownloadRecord',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file_size', models.BigIntegerField(default=0)),
|
||||
('downloaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to='books.ebook')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Download Record',
|
||||
'verbose_name_plural': 'Download Records',
|
||||
'db_table': 'books_download_record',
|
||||
'ordering': ['-downloaded_at'],
|
||||
},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='ReadingSettings',
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='bookchapter',
|
||||
index=models.Index(fields=['ebook', 'index'], name='books_book__ebook_i_464cd6_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='downloadrecord',
|
||||
unique_together={('user', 'ebook')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="readingprogress",
|
||||
name="epub_location",
|
||||
field=models.CharField(blank=True, default="", max_length=2048),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,156 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ReadingStatus(models.TextChoices):
|
||||
WANT_TO_READ = "want_to_read", "Want to Read"
|
||||
READING = "reading", "Reading"
|
||||
FINISHED = "finished", "Finished"
|
||||
DNF = "dnf", "Did Not Finish"
|
||||
|
||||
|
||||
class Book(models.Model):
|
||||
title = models.CharField(max_length=512, db_index=True)
|
||||
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
|
||||
genre = models.CharField(max_length=128, blank=True, default="", db_index=True)
|
||||
description = models.TextField(blank=True, default="")
|
||||
reading_status = models.CharField(max_length=20, choices=ReadingStatus.choices, default=ReadingStatus.WANT_TO_READ, db_index=True)
|
||||
total_pages = models.PositiveIntegerField(default=0)
|
||||
cover_image = models.URLField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "books_book"
|
||||
verbose_name = "Book"
|
||||
verbose_name_plural = "Books"
|
||||
ordering = ["title"]
|
||||
indexes = [models.Index(fields=["title", "author", "genre"])]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class EBook(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks")
|
||||
title = models.CharField(max_length=512, db_index=True)
|
||||
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
|
||||
format = models.CharField(max_length=20, blank=True, default="", editable=False)
|
||||
page_count = models.PositiveIntegerField(default=0)
|
||||
file_size = models.BigIntegerField(default=0)
|
||||
metadata_json = models.JSONField(blank=True, default=dict)
|
||||
file = models.FileField(upload_to="ebooks/%Y/%m/%d/")
|
||||
cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "books_ebook"
|
||||
verbose_name = "E-Book"
|
||||
verbose_name_plural = "E-Books"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [models.Index(fields=["user", "-created_at"])]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def filename(self):
|
||||
return Path(self.file.name).name if self.file else ""
|
||||
|
||||
|
||||
class BookChapter(models.Model):
|
||||
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="chapters")
|
||||
title = models.CharField(max_length=512)
|
||||
index = models.IntegerField(default=0)
|
||||
href = models.CharField(max_length=1024, blank=True, default="")
|
||||
children = models.JSONField(blank=True, default=list)
|
||||
|
||||
class Meta:
|
||||
db_table = "books_book_chapter"
|
||||
verbose_name = "Book Chapter"
|
||||
verbose_name_plural = "Book Chapters"
|
||||
ordering = ["index"]
|
||||
indexes = [models.Index(fields=["ebook", "index"])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.ebook.title} - {self.title}"
|
||||
|
||||
|
||||
@receiver(post_delete, sender=EBook)
|
||||
def _auto_delete_ebook_file(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
instance.file.delete(save=False)
|
||||
if instance.cover_image:
|
||||
instance.cover_image.delete(save=False)
|
||||
|
||||
|
||||
class DownloadRecord(models.Model):
|
||||
"""Tracks book downloads for offline access management."""
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="download_records")
|
||||
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="download_records")
|
||||
file_size = models.BigIntegerField(default=0)
|
||||
downloaded_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "books_download_record"
|
||||
verbose_name = "Download Record"
|
||||
verbose_name_plural = "Download Records"
|
||||
ordering = ["-downloaded_at"]
|
||||
unique_together = [("user", "ebook")]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.ebook.title}"
|
||||
|
||||
|
||||
class ReadingProgress(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress")
|
||||
ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress")
|
||||
current_position = models.FloatField(default=0.0)
|
||||
last_page = models.IntegerField(default=0)
|
||||
epub_location = models.CharField(max_length=2048, blank=True, default="")
|
||||
device_id = models.CharField(max_length=128, blank=True, default="")
|
||||
device_name = models.CharField(max_length=128, blank=True, default="")
|
||||
version = models.PositiveIntegerField(default=1)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "books_reading_progress"
|
||||
verbose_name_plural = "reading progress"
|
||||
unique_together = [("user", "ebook")]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.ebook.title} - {self.current_position:.1f}%"
|
||||
|
||||
def update_with_sync(self, position: float, last_page: int,
|
||||
device_id: str, device_name: str,
|
||||
client_updated_at: str | None = None) -> tuple["ReadingProgress", bool]:
|
||||
"""Update progress with conflict resolution (last-write-wins by timestamp).
|
||||
|
||||
Returns (instance, applied) where applied is True if the update was applied.
|
||||
"""
|
||||
if client_updated_at and self.updated_at:
|
||||
try:
|
||||
from django.utils.timezone import is_naive, make_aware
|
||||
from datetime import datetime
|
||||
client_dt = datetime.fromisoformat(client_updated_at.replace("Z", "+00:00"))
|
||||
if is_naive(client_dt):
|
||||
client_dt = make_aware(client_dt)
|
||||
if client_dt <= self.updated_at:
|
||||
return self, False
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
self.current_position = position
|
||||
self.last_page = last_page
|
||||
self.device_id = device_id
|
||||
self.device_name = device_name
|
||||
self.version += 1
|
||||
self.save(update_fields=[
|
||||
"current_position", "last_page",
|
||||
"device_id", "device_name", "version", "updated_at",
|
||||
])
|
||||
return self, True
|
||||
@@ -0,0 +1,231 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
import logging
|
||||
|
||||
from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord
|
||||
from apps.books.services.ebook_metadata import subjects_from_ebook
|
||||
from apps.books.services.metadata import enrich_ebook_metadata
|
||||
from apps.books.services.process_ebook import apply_processing_to_ebook
|
||||
from apps.reader.models import ReadingSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BookReadingSettingsSerializer(serializers.ModelSerializer):
|
||||
font_style = serializers.CharField(source="font_family")
|
||||
|
||||
class Meta:
|
||||
model = ReadingSettings
|
||||
fields = ["font_size", "font_style", "background_color"]
|
||||
|
||||
def validate_font_size(self, value: int) -> int:
|
||||
if value < 12 or value > 36:
|
||||
raise serializers.ValidationError("Font size must be between 12 and 36.")
|
||||
return value
|
||||
|
||||
|
||||
class BookChapterSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = BookChapter
|
||||
fields = ["id", "title", "index", "href", "children"]
|
||||
|
||||
|
||||
class EBookContentSerializer(serializers.Serializer):
|
||||
page = serializers.IntegerField()
|
||||
total_pages = serializers.IntegerField()
|
||||
content = serializers.CharField()
|
||||
chapter_title = serializers.CharField()
|
||||
format = serializers.CharField()
|
||||
|
||||
|
||||
class EBookTocSerializer(serializers.Serializer):
|
||||
chapters = serializers.ListField(child=BookChapterSerializer())
|
||||
format = serializers.CharField()
|
||||
page_count = serializers.IntegerField()
|
||||
|
||||
|
||||
class BookListSerializer(serializers.ModelSerializer):
|
||||
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = ["id", "title", "author", "genre", "reading_status", "reading_status_display", "cover_image"]
|
||||
|
||||
|
||||
class BookDetailSerializer(serializers.ModelSerializer):
|
||||
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = ["id", "title", "author", "genre", "description", "reading_status", "reading_status_display", "cover_image", "total_pages", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class BookSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = ["id", "title", "author", "genre", "description", "reading_status", "cover_image", "total_pages", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class EBookListSerializer(serializers.ModelSerializer):
|
||||
filename = serializers.CharField(read_only=True)
|
||||
format = serializers.CharField(read_only=True)
|
||||
progress = serializers.SerializerMethodField()
|
||||
started = serializers.SerializerMethodField()
|
||||
subjects = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = EBook
|
||||
fields = [
|
||||
"id", "title", "author", "filename", "format", "page_count", "file_size",
|
||||
"cover_image", "created_at", "progress", "started", "subjects",
|
||||
]
|
||||
|
||||
def get_subjects(self, obj: EBook) -> list[str]:
|
||||
return subjects_from_ebook(obj)
|
||||
|
||||
def get_progress(self, obj):
|
||||
try:
|
||||
return obj.reading_progress.current_position
|
||||
except ReadingProgress.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_started(self, obj):
|
||||
"""True when the user has opened the reader with saved progress."""
|
||||
try:
|
||||
rp = obj.reading_progress
|
||||
except ReadingProgress.DoesNotExist:
|
||||
return False
|
||||
if rp.current_position >= 99:
|
||||
return False
|
||||
if obj.format == "pdf":
|
||||
return rp.last_page > 0 or rp.current_position > 0
|
||||
return bool((rp.epub_location or "").strip())
|
||||
|
||||
|
||||
class EBookDetailSerializer(serializers.ModelSerializer):
|
||||
filename = serializers.CharField(read_only=True)
|
||||
format = serializers.CharField(read_only=True)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
progress = serializers.SerializerMethodField()
|
||||
metadata = serializers.JSONField(source="metadata_json", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = EBook
|
||||
fields = [
|
||||
"id", "title", "author", "filename", "format", "page_count", "file_size",
|
||||
"file_url", "cover_image", "metadata", "created_at", "updated_at", "progress",
|
||||
]
|
||||
|
||||
def get_file_url(self, obj):
|
||||
request = self.context.get("request")
|
||||
if request and obj.file:
|
||||
return request.build_absolute_uri(obj.file.url)
|
||||
return ""
|
||||
|
||||
def get_progress(self, obj):
|
||||
try:
|
||||
rp = obj.reading_progress
|
||||
return {
|
||||
"current_position": rp.current_position,
|
||||
"last_page": rp.last_page,
|
||||
"epub_location": rp.epub_location,
|
||||
}
|
||||
except ReadingProgress.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class EBookUploadSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = EBook
|
||||
fields = ["title", "author", "file", "cover_image"]
|
||||
extra_kwargs = {"title": {"required": True}, "file": {"required": True}}
|
||||
|
||||
def validate_file(self, value):
|
||||
import os
|
||||
if value is None:
|
||||
return value
|
||||
ext = os.path.splitext(str(getattr(value, "name", "")))[1].lower()
|
||||
if ext not in (".epub", ".pdf"):
|
||||
raise serializers.ValidationError("Only EPUB and PDF files are supported.")
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
import os
|
||||
|
||||
validated_data["user"] = self.context["request"].user
|
||||
name = str(getattr(validated_data.get("file"), "name", ""))
|
||||
ext = os.path.splitext(name)[1].lower().lstrip(".")
|
||||
if ext:
|
||||
validated_data["format"] = ext
|
||||
ebook = super().create(validated_data)
|
||||
try:
|
||||
apply_processing_to_ebook(ebook)
|
||||
except Exception:
|
||||
logger.exception("E-book processing failed for ebook %s", ebook.pk)
|
||||
try:
|
||||
enrich_ebook_metadata(ebook)
|
||||
except Exception:
|
||||
logger.exception("Metadata enrichment failed for ebook %s", ebook.pk)
|
||||
ebook.refresh_from_db()
|
||||
return ebook
|
||||
|
||||
|
||||
class ReadingProgressSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ReadingProgress
|
||||
fields = [
|
||||
"current_position", "last_page", "epub_location",
|
||||
"device_id", "device_name", "version", "updated_at",
|
||||
]
|
||||
read_only_fields = ["version", "updated_at"]
|
||||
extra_kwargs = {"current_position": {"required": False, "min_value": 0.0, "max_value": 100.0}}
|
||||
|
||||
def validate_current_position(self, value):
|
||||
if value < 0.0 or value > 100.0:
|
||||
raise serializers.ValidationError("Position must be between 0.0 and 100.0.")
|
||||
return value
|
||||
|
||||
|
||||
class DownloadRecordSerializer(serializers.ModelSerializer):
|
||||
ebook_id = serializers.IntegerField(source="ebook.id", read_only=True)
|
||||
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
||||
author = serializers.CharField(source="ebook.author", read_only=True)
|
||||
filename = serializers.SerializerMethodField()
|
||||
cover_image = serializers.ImageField(source="ebook.cover_image", read_only=True)
|
||||
format = serializers.CharField(source="ebook.format", read_only=True)
|
||||
progress = serializers.SerializerMethodField()
|
||||
file_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DownloadRecord
|
||||
fields = [
|
||||
"id", "ebook_id", "ebook_title", "author", "filename", "file_url",
|
||||
"file_size", "cover_image", "format", "downloaded_at", "progress",
|
||||
]
|
||||
|
||||
def get_filename(self, obj):
|
||||
return obj.ebook.filename()
|
||||
|
||||
def get_file_url(self, obj):
|
||||
request = self.context.get("request")
|
||||
if request and obj.ebook.file:
|
||||
return request.build_absolute_uri(obj.ebook.file.url)
|
||||
return ""
|
||||
|
||||
def get_progress(self, obj):
|
||||
try:
|
||||
rp = obj.ebook.reading_progress
|
||||
return {
|
||||
"current_position": rp.current_position,
|
||||
"last_page": rp.last_page,
|
||||
"epub_location": rp.epub_location,
|
||||
}
|
||||
except ReadingProgress.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class StorageSummarySerializer(serializers.Serializer):
|
||||
total_downloads = serializers.IntegerField()
|
||||
total_size_bytes = serializers.IntegerField()
|
||||
ebooks = serializers.ListField(child=serializers.DictField())
|
||||
@@ -0,0 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from apps.books.models import EBook
|
||||
|
||||
|
||||
def subjects_from_ebook(ebook: EBook) -> list[str]:
|
||||
ol = (ebook.metadata_json or {}).get("openlibrary") or {}
|
||||
return [s.strip() for s in (ol.get("subjects") or []) if isinstance(s, str) and s.strip()]
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from apps.books.models import EBook
|
||||
from apps.books.services.openlibrary import (
|
||||
HIGH_CONFIDENCE,
|
||||
download_cover,
|
||||
fetch_metadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def enrich_ebook_metadata(ebook: EBook) -> EBook:
|
||||
"""Fetch Open Library metadata and update the ebook. Never raises to callers."""
|
||||
user_title = ebook.title
|
||||
user_author = ebook.author or ""
|
||||
|
||||
try:
|
||||
result = fetch_metadata(user_title, user_author)
|
||||
except Exception:
|
||||
logger.exception("Open Library metadata fetch failed for ebook %s", ebook.pk)
|
||||
return ebook
|
||||
|
||||
if result is None:
|
||||
return ebook
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
metadata = {
|
||||
"source": "openlibrary",
|
||||
"matched_at": now,
|
||||
"match_language": result.match_language,
|
||||
"match_score": result.match_score,
|
||||
"match_status": result.match_status,
|
||||
"user_input": {"title": user_title, "author": user_author},
|
||||
"openlibrary": result.openlibrary,
|
||||
}
|
||||
ebook.metadata_json = metadata
|
||||
|
||||
update_fields = ["metadata_json", "updated_at"]
|
||||
|
||||
if result.match_status == "not_found":
|
||||
ebook.save(update_fields=update_fields)
|
||||
return ebook
|
||||
|
||||
ol = result.openlibrary
|
||||
cover_id = ol.get("cover_id")
|
||||
|
||||
cover_missing = (
|
||||
not ebook.cover_image
|
||||
or not ebook.cover_image.name
|
||||
or not ebook.cover_image.storage.exists(ebook.cover_image.name)
|
||||
)
|
||||
if cover_id and cover_missing:
|
||||
if ebook.cover_image:
|
||||
ebook.cover_image.delete(save=False)
|
||||
cover_bytes = download_cover(int(cover_id))
|
||||
if cover_bytes:
|
||||
filename = f"ol_cover_{ebook.pk}_{cover_id}.jpg"
|
||||
ebook.cover_image.save(filename, ContentFile(cover_bytes), save=False)
|
||||
update_fields.append("cover_image")
|
||||
|
||||
if result.match_score >= HIGH_CONFIDENCE:
|
||||
ol_title = ol.get("title")
|
||||
ol_authors = ol.get("authors") or []
|
||||
if ol_title:
|
||||
ebook.title = ol_title[:512]
|
||||
update_fields.append("title")
|
||||
if ol_authors:
|
||||
ebook.author = ol_authors[0][:256]
|
||||
update_fields.append("author")
|
||||
|
||||
ebook.save(update_fields=list(dict.fromkeys(update_fields)))
|
||||
return ebook
|
||||
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from config.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEARCH_URL = "https://openlibrary.org/search.json"
|
||||
COVERS_URL = "https://covers.openlibrary.org/b/id/{cover_id}-{size}.jpg"
|
||||
SEARCH_FIELDS = (
|
||||
"key,title,author_name,cover_i,first_publish_year,subject,language,"
|
||||
"edition_key,number_of_pages_median,publisher"
|
||||
)
|
||||
|
||||
HIGH_CONFIDENCE = 0.8
|
||||
LOW_CONFIDENCE = 0.6
|
||||
|
||||
# EPUB release noise often copied from filenames, e.g. "Title [6494] (r2.3)"
|
||||
_TITLE_NOISE_PATTERNS = (
|
||||
re.compile(r"\s*\[\d+\]"), # [6494]
|
||||
re.compile(r"\s*\([rv][\d.]+\)", re.IGNORECASE), # (r2.3), (v1.0)
|
||||
re.compile(r"\s*\(rev[\d.]*\)", re.IGNORECASE), # (rev2)
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_search_title(title: str) -> str:
|
||||
cleaned = title.strip()
|
||||
for pattern in _TITLE_NOISE_PATTERNS:
|
||||
cleaned = pattern.sub("", cleaned)
|
||||
return re.sub(r"\s+", " ", cleaned).strip()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenLibraryHit:
|
||||
work_key: str
|
||||
title: str
|
||||
authors: list[str]
|
||||
cover_id: int | None
|
||||
first_publish_year: int | None
|
||||
subjects: list[str]
|
||||
languages: list[str]
|
||||
publishers: list[str]
|
||||
edition_key: str | None
|
||||
number_of_pages_median: int | None
|
||||
match_language: str
|
||||
score: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenLibraryMetadata:
|
||||
match_language: str
|
||||
match_score: float
|
||||
match_status: str
|
||||
openlibrary: dict[str, Any]
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
cleaned = re.sub(r"[^\w\s]", " ", _strip_accents(text).lower())
|
||||
return re.sub(r"\s+", " ", cleaned).strip()
|
||||
|
||||
|
||||
def _strip_accents(text: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", text)
|
||||
return "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
||||
|
||||
|
||||
def _build_search_params(
|
||||
title: str,
|
||||
author: str,
|
||||
*,
|
||||
lang: str | None,
|
||||
search_mode: str,
|
||||
) -> dict[str, str | int]:
|
||||
title = title.strip()
|
||||
author = author.strip()
|
||||
params: dict[str, str | int] = {"limit": 10, "fields": SEARCH_FIELDS}
|
||||
|
||||
if search_mode == "spanish_q":
|
||||
q_parts = ["language:spa", title]
|
||||
if author:
|
||||
q_parts.append(author)
|
||||
params["q"] = " ".join(q_parts)
|
||||
elif search_mode == "q":
|
||||
params["q"] = " ".join(part for part in (title, author) if part)
|
||||
elif search_mode == "q_unaccent":
|
||||
params["q"] = " ".join(
|
||||
part for part in (_strip_accents(title), _strip_accents(author) if author else "") if part
|
||||
)
|
||||
else:
|
||||
params["title"] = title
|
||||
if author:
|
||||
params["author"] = author
|
||||
|
||||
if lang:
|
||||
params["lang"] = lang
|
||||
return params
|
||||
|
||||
|
||||
def _title_similarity(a: str, b: str) -> float:
|
||||
na, nb = _normalize(a), _normalize(b)
|
||||
if not na or not nb:
|
||||
return 0.0
|
||||
if na in nb or nb in na:
|
||||
return 1.0
|
||||
return SequenceMatcher(None, na, nb).ratio()
|
||||
|
||||
|
||||
def _author_overlap(user_author: str, ol_authors: list[str]) -> float:
|
||||
if not user_author.strip():
|
||||
return 0.5 if ol_authors else 0.0
|
||||
user_tokens = set(_normalize(user_author).split())
|
||||
if not user_tokens:
|
||||
return 0.0
|
||||
best = 0.0
|
||||
for name in ol_authors:
|
||||
name_tokens = set(_normalize(name).split())
|
||||
if not name_tokens:
|
||||
continue
|
||||
overlap = len(user_tokens & name_tokens) / len(user_tokens)
|
||||
best = max(best, overlap)
|
||||
if user_tokens <= name_tokens or name_tokens <= user_tokens:
|
||||
best = max(best, 0.95)
|
||||
return best
|
||||
|
||||
|
||||
def _score_hit(title: str, author: str, doc: dict[str, Any], *, lang: str) -> float:
|
||||
ol_title = doc.get("title") or ""
|
||||
ol_authors = doc.get("author_name") or []
|
||||
title_score = _title_similarity(title, ol_title)
|
||||
author_score = _author_overlap(author, ol_authors)
|
||||
combined = (title_score * 0.6) + (author_score * 0.4)
|
||||
if doc.get("cover_i"):
|
||||
combined += 0.05
|
||||
return min(combined, 1.0)
|
||||
|
||||
|
||||
def _parse_hit(doc: dict[str, Any], *, lang: str, score: float) -> OpenLibraryHit:
|
||||
edition_keys = doc.get("edition_key") or []
|
||||
edition_key = edition_keys[0] if edition_keys else None
|
||||
cover_id = doc.get("cover_i")
|
||||
return OpenLibraryHit(
|
||||
work_key=doc.get("key") or "",
|
||||
title=doc.get("title") or "",
|
||||
authors=list(doc.get("author_name") or []),
|
||||
cover_id=int(cover_id) if cover_id else None,
|
||||
first_publish_year=doc.get("first_publish_year"),
|
||||
subjects=list(doc.get("subject") or [])[:10],
|
||||
languages=list(doc.get("language") or []),
|
||||
publishers=list(doc.get("publisher") or [])[:5],
|
||||
edition_key=edition_key,
|
||||
number_of_pages_median=doc.get("number_of_pages_median"),
|
||||
match_language=lang,
|
||||
score=score,
|
||||
)
|
||||
|
||||
|
||||
def build_cover_url(cover_id: int, size: str = "L") -> str:
|
||||
return COVERS_URL.format(cover_id=cover_id, size=size)
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
read_timeout = settings.OPENLIBRARY_TIMEOUT_SECONDS
|
||||
connect_timeout = settings.OPENLIBRARY_CONNECT_TIMEOUT_SECONDS
|
||||
return httpx.Client(
|
||||
timeout=httpx.Timeout(
|
||||
connect=connect_timeout,
|
||||
read=read_timeout,
|
||||
write=read_timeout,
|
||||
pool=connect_timeout,
|
||||
),
|
||||
headers={"User-Agent": settings.OPENLIBRARY_USER_AGENT},
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
|
||||
def search_works(
|
||||
title: str,
|
||||
author: str,
|
||||
*,
|
||||
lang: str | None = None,
|
||||
search_mode: str = "title",
|
||||
client: httpx.Client | None = None,
|
||||
) -> list[OpenLibraryHit]:
|
||||
if not title.strip():
|
||||
return []
|
||||
|
||||
params = _build_search_params(title, author, lang=lang, search_mode=search_mode)
|
||||
|
||||
try:
|
||||
if client is not None:
|
||||
response = client.get(SEARCH_URL, params=params)
|
||||
response.raise_for_status()
|
||||
docs = response.json().get("docs") or []
|
||||
else:
|
||||
with _client() as owned_client:
|
||||
response = owned_client.get(SEARCH_URL, params=params)
|
||||
response.raise_for_status()
|
||||
docs = response.json().get("docs") or []
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning("Open Library search failed: %s", exc)
|
||||
return []
|
||||
|
||||
hits: list[OpenLibraryHit] = []
|
||||
for doc in docs:
|
||||
score = _score_hit(title, author, doc, lang=lang or "")
|
||||
if score < LOW_CONFIDENCE:
|
||||
continue
|
||||
hits.append(_parse_hit(doc, lang=lang or "", score=score))
|
||||
hits.sort(key=lambda h: (h.score, h.cover_id is not None), reverse=True)
|
||||
return hits
|
||||
|
||||
|
||||
def pick_best_match(title: str, author: str, hits: list[OpenLibraryHit]) -> OpenLibraryHit | None:
|
||||
return hits[0] if hits else None
|
||||
|
||||
|
||||
def download_cover(cover_id: int) -> bytes | None:
|
||||
url = build_cover_url(cover_id, size="L")
|
||||
try:
|
||||
with _client() as client:
|
||||
response = client.get(url)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if not content_type.startswith("image/"):
|
||||
return None
|
||||
return response.content
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Open Library cover download failed for %s: %s", cover_id, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _hit_to_openlibrary_dict(hit: OpenLibraryHit) -> dict[str, Any]:
|
||||
return {
|
||||
"work_key": hit.work_key,
|
||||
"edition_key": hit.edition_key,
|
||||
"title": hit.title,
|
||||
"authors": hit.authors,
|
||||
"cover_id": hit.cover_id,
|
||||
"cover_url": build_cover_url(hit.cover_id) if hit.cover_id else None,
|
||||
"first_publish_year": hit.first_publish_year,
|
||||
"subjects": hit.subjects,
|
||||
"languages": hit.languages,
|
||||
"publishers": hit.publishers,
|
||||
"number_of_pages_median": hit.number_of_pages_median,
|
||||
}
|
||||
|
||||
|
||||
def fetch_metadata(title: str, author: str) -> OpenLibraryMetadata | None:
|
||||
if not settings.OPENLIBRARY_ENABLED:
|
||||
return None
|
||||
|
||||
preferred = settings.OPENLIBRARY_PREFERRED_LANG
|
||||
fallback = settings.OPENLIBRARY_FALLBACK_LANG
|
||||
search_title = _sanitize_search_title(title)
|
||||
|
||||
if not search_title:
|
||||
return OpenLibraryMetadata(
|
||||
match_language=preferred,
|
||||
match_score=0.0,
|
||||
match_status="not_found",
|
||||
openlibrary={},
|
||||
)
|
||||
|
||||
search_plan: list[tuple[str, str | None]] = [
|
||||
("spanish_q", preferred),
|
||||
("title", preferred),
|
||||
("q", None),
|
||||
("q_unaccent", None),
|
||||
]
|
||||
if fallback != preferred:
|
||||
search_plan.extend([("title", fallback), ("q", fallback)])
|
||||
|
||||
hits: list[OpenLibraryHit] = []
|
||||
with _client() as client:
|
||||
for search_mode, lang in search_plan:
|
||||
hits = search_works(search_title, author, lang=lang, search_mode=search_mode, client=client)
|
||||
if hits:
|
||||
break
|
||||
|
||||
hit = pick_best_match(title, author, hits)
|
||||
if not hit:
|
||||
return OpenLibraryMetadata(
|
||||
match_language=preferred,
|
||||
match_score=0.0,
|
||||
match_status="not_found",
|
||||
openlibrary={},
|
||||
)
|
||||
|
||||
status = "matched" if hit.score >= HIGH_CONFIDENCE else "partial"
|
||||
return OpenLibraryMetadata(
|
||||
match_language=hit.match_language,
|
||||
match_score=hit.score,
|
||||
match_status=status,
|
||||
openlibrary=_hit_to_openlibrary_dict(hit),
|
||||
)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Extract TOC, metadata, and page count from uploaded EPUB/PDF files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from apps.books.models import BookChapter, EBook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def process_ebook(file_path: str, original_filename: str | None = None) -> dict[str, Any]:
|
||||
"""Parse an e-book file and return format, metadata, TOC, and page count."""
|
||||
ext = Path(original_filename or file_path).suffix.lower()
|
||||
if ext == ".epub" or file_path.lower().endswith(".epub"):
|
||||
return _process_epub(file_path)
|
||||
if ext == ".pdf" or file_path.lower().endswith(".pdf"):
|
||||
return _process_pdf(file_path)
|
||||
return {"format": ext.lstrip(".") or "unknown", "page_count": 0, "metadata": {}, "toc": []}
|
||||
|
||||
|
||||
def store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None:
|
||||
"""Recursively store TOC entries as BookChapter records."""
|
||||
for idx, entry in enumerate(toc):
|
||||
BookChapter.objects.create(
|
||||
ebook=ebook,
|
||||
title=entry.get("title", "Untitled"),
|
||||
index=parent_index + idx,
|
||||
href=entry.get("href", ""),
|
||||
children=entry.get("children", []),
|
||||
)
|
||||
children = entry.get("children", [])
|
||||
if children:
|
||||
store_chapters(ebook, children, parent_index + idx + 1)
|
||||
|
||||
|
||||
def apply_processing_to_ebook(ebook: EBook) -> dict[str, Any]:
|
||||
"""Run processing on an EBook instance and persist chapters + metadata."""
|
||||
if not ebook.file:
|
||||
raise ValueError("No file found for this e-book.")
|
||||
|
||||
file_path = ebook.file.path
|
||||
result = process_ebook(file_path, original_filename=ebook.filename())
|
||||
|
||||
ebook.format = result.get("format", ebook.format)
|
||||
ebook.page_count = result.get("page_count", 0)
|
||||
file_metadata = result.get("metadata") or {}
|
||||
if file_metadata:
|
||||
merged = dict(ebook.metadata_json or {})
|
||||
merged["file"] = file_metadata
|
||||
ebook.metadata_json = merged
|
||||
ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"])
|
||||
|
||||
raw_toc: list[dict[str, Any]] = result.get("toc", [])
|
||||
BookChapter.objects.filter(ebook=ebook).delete()
|
||||
store_chapters(ebook, raw_toc)
|
||||
|
||||
return {
|
||||
"format": ebook.format,
|
||||
"page_count": ebook.page_count,
|
||||
"metadata": ebook.metadata_json,
|
||||
"toc_count": len(raw_toc),
|
||||
"status": "processed",
|
||||
}
|
||||
|
||||
|
||||
def _process_epub(file_path: str) -> dict[str, Any]:
|
||||
from ebooklib import epub, ITEM_DOCUMENT
|
||||
|
||||
book = epub.read_epub(file_path)
|
||||
metadata = _extract_epub_metadata(book)
|
||||
toc = _extract_epub_toc(book)
|
||||
|
||||
if not toc:
|
||||
toc = _fallback_toc_from_spine(book, ITEM_DOCUMENT)
|
||||
|
||||
flat_count = _count_toc_entries(toc)
|
||||
page_count = flat_count or len(book.spine)
|
||||
|
||||
return {
|
||||
"format": "epub",
|
||||
"page_count": page_count,
|
||||
"metadata": metadata,
|
||||
"toc": toc,
|
||||
}
|
||||
|
||||
|
||||
def _process_pdf(file_path: str) -> dict[str, Any]:
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(file_path)
|
||||
page_count = len(reader.pages)
|
||||
metadata = _extract_pdf_metadata(reader)
|
||||
toc = _extract_pdf_outline(reader)
|
||||
if not toc and page_count > 0:
|
||||
toc = [
|
||||
{"title": f"Page {i}", "href": f"pdf:page:{i}", "children": []}
|
||||
for i in range(1, page_count + 1)
|
||||
]
|
||||
return {
|
||||
"format": "pdf",
|
||||
"page_count": page_count,
|
||||
"metadata": metadata,
|
||||
"toc": toc,
|
||||
}
|
||||
|
||||
|
||||
def _extract_pdf_metadata(reader) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {}
|
||||
info = reader.metadata
|
||||
if not info:
|
||||
return metadata
|
||||
title = getattr(info, "title", None)
|
||||
author = getattr(info, "author", None)
|
||||
if title:
|
||||
metadata["title"] = str(title)
|
||||
if author:
|
||||
metadata["author"] = str(author)
|
||||
return metadata
|
||||
|
||||
|
||||
def _extract_pdf_outline(reader) -> list[dict[str, Any]]:
|
||||
outline = getattr(reader, "outline", None)
|
||||
if not outline:
|
||||
return []
|
||||
return _walk_pdf_outline(reader, outline)
|
||||
|
||||
|
||||
def _walk_pdf_outline(reader, outline: list[Any]) -> list[dict[str, Any]]:
|
||||
entries: list[dict[str, Any]] = []
|
||||
i = 0
|
||||
while i < len(outline):
|
||||
item = outline[i]
|
||||
if isinstance(item, list):
|
||||
if entries:
|
||||
entries[-1]["children"] = _walk_pdf_outline(reader, item)
|
||||
i += 1
|
||||
continue
|
||||
title = getattr(item, "title", None) or "Section"
|
||||
page_num = 1
|
||||
try:
|
||||
page_num = reader.get_destination_page_number(item) + 1
|
||||
except Exception:
|
||||
logger.debug("Could not resolve PDF outline destination", exc_info=True)
|
||||
entries.append({
|
||||
"title": str(title),
|
||||
"href": f"pdf:page:{page_num}",
|
||||
"children": [],
|
||||
})
|
||||
i += 1
|
||||
return entries
|
||||
|
||||
|
||||
def _extract_epub_metadata(book) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
def first(namespace: str, name: str) -> str:
|
||||
values = book.get_metadata(namespace, name)
|
||||
if values:
|
||||
return str(values[0][0])
|
||||
return ""
|
||||
|
||||
title = first("DC", "title")
|
||||
if title:
|
||||
metadata["title"] = title
|
||||
creator = first("DC", "creator")
|
||||
if creator:
|
||||
metadata["author"] = creator
|
||||
language = first("DC", "language")
|
||||
if language:
|
||||
metadata["language"] = language
|
||||
identifier = first("DC", "identifier")
|
||||
if identifier:
|
||||
metadata["identifier"] = identifier
|
||||
return metadata
|
||||
|
||||
|
||||
def _parse_toc_item(item) -> dict[str, Any]:
|
||||
from ebooklib import epub
|
||||
|
||||
if isinstance(item, epub.Link):
|
||||
return {
|
||||
"title": item.title or "Untitled",
|
||||
"href": item.href or "",
|
||||
"children": [],
|
||||
}
|
||||
if isinstance(item, tuple):
|
||||
section, children = item
|
||||
entry = {
|
||||
"title": getattr(section, "title", None) or "Untitled",
|
||||
"href": getattr(section, "href", None) or "",
|
||||
"children": [],
|
||||
}
|
||||
for child in children:
|
||||
entry["children"].append(_parse_toc_item(child))
|
||||
return entry
|
||||
if hasattr(item, "title"):
|
||||
return {
|
||||
"title": item.title or "Untitled",
|
||||
"href": getattr(item, "href", "") or "",
|
||||
"children": [],
|
||||
}
|
||||
return {"title": "Untitled", "href": "", "children": []}
|
||||
|
||||
|
||||
def _extract_epub_toc(book) -> list[dict[str, Any]]:
|
||||
return [_parse_toc_item(item) for item in book.toc]
|
||||
|
||||
|
||||
def _fallback_toc_from_spine(book, item_document_type) -> list[dict[str, Any]]:
|
||||
toc: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
chapter_num = 0
|
||||
|
||||
for spine_entry in book.spine:
|
||||
item_id = spine_entry[0] if isinstance(spine_entry, tuple) else spine_entry
|
||||
item = book.get_item_with_id(item_id)
|
||||
if not item or item.get_type() != item_document_type:
|
||||
continue
|
||||
href = item.get_name() or ""
|
||||
if not href or href in seen:
|
||||
continue
|
||||
seen.add(href)
|
||||
chapter_num += 1
|
||||
title = os.path.splitext(os.path.basename(href))[0] or f"Chapter {chapter_num}"
|
||||
toc.append({"title": title.replace("_", " ").replace("-", " "), "href": href, "children": []})
|
||||
|
||||
return toc
|
||||
|
||||
|
||||
def _count_toc_entries(toc: list[dict[str, Any]]) -> int:
|
||||
count = 0
|
||||
for entry in toc:
|
||||
count += 1
|
||||
count += _count_toc_entries(entry.get("children", []))
|
||||
return count
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Tests for the Book search & discovery endpoints."""
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.books.models import Book, ReadingStatus
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(django_user_model):
|
||||
return django_user_model.objects.create_user(
|
||||
email="reader@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(api_client, user):
|
||||
api_client.force_authenticate(user=user)
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def books():
|
||||
books_data = [
|
||||
Book.objects.create(
|
||||
title="Dune",
|
||||
author="Frank Herbert",
|
||||
genre="Science Fiction",
|
||||
reading_status=ReadingStatus.FINISHED,
|
||||
total_pages=688,
|
||||
description="A desert planet saga.",
|
||||
),
|
||||
Book.objects.create(
|
||||
title="Neuromancer",
|
||||
author="William Gibson",
|
||||
genre="Science Fiction",
|
||||
reading_status=ReadingStatus.READING,
|
||||
total_pages=271,
|
||||
description="Cyberpunk classic.",
|
||||
),
|
||||
Book.objects.create(
|
||||
title="The Hobbit",
|
||||
author="J.R.R. Tolkien",
|
||||
genre="Fantasy",
|
||||
reading_status=ReadingStatus.WANT_TO_READ,
|
||||
total_pages=310,
|
||||
description="A hobbit's adventure.",
|
||||
),
|
||||
Book.objects.create(
|
||||
title="1984",
|
||||
author="George Orwell",
|
||||
genre="Dystopian",
|
||||
reading_status=ReadingStatus.FINISHED,
|
||||
total_pages=328,
|
||||
description="Big Brother is watching.",
|
||||
),
|
||||
]
|
||||
return books_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBookSearch:
|
||||
"""Verify the search endpoint returns correct results."""
|
||||
|
||||
def test_search_by_title(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Dune"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Dune" in titles
|
||||
assert "Neuromancer" not in titles
|
||||
|
||||
def test_search_by_author(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Tolkien"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "The Hobbit" in titles
|
||||
|
||||
def test_search_by_genre(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Fantasy"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "The Hobbit" in titles
|
||||
|
||||
def test_search_case_insensitive(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "dune"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert any(b["title"] == "Dune" for b in response.data["results"])
|
||||
|
||||
def test_search_partial_match(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Neu"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Neuromancer" in titles
|
||||
|
||||
def test_search_empty_query_returns_all(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": ""})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 4
|
||||
|
||||
def test_search_no_results(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "zzzznotfound"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data["results"]) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBookFilters:
|
||||
"""Verify filters for genre, author, and reading_status."""
|
||||
|
||||
def test_filter_by_genre(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"genre": "Science Fiction"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Dune" in titles
|
||||
assert "Neuromancer" in titles
|
||||
assert "The Hobbit" not in titles
|
||||
|
||||
def test_filter_by_author(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"author": "George Orwell"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "1984" in titles
|
||||
assert "Dune" not in titles
|
||||
|
||||
def test_filter_by_reading_status(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"reading_status": ReadingStatus.FINISHED})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Dune" in titles
|
||||
assert "1984" in titles
|
||||
assert "Neuromancer" not in titles
|
||||
assert "The Hobbit" not in titles
|
||||
|
||||
def test_filter_combined_with_search(self, auth_client, books):
|
||||
"""Search + filter should intersect results."""
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"q": "Dune", "reading_status": ReadingStatus.FINISHED})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert "Dune" in titles
|
||||
# 1984 matches reading_status but not search
|
||||
assert "1984" not in titles
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBookDiscovery:
|
||||
"""Verify genre and author discovery endpoints."""
|
||||
|
||||
def test_genres_endpoint(self, auth_client, books):
|
||||
url = reverse("book-genres")
|
||||
response = auth_client.get(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert isinstance(response.data, list)
|
||||
assert "Science Fiction" in response.data
|
||||
assert "Fantasy" in response.data
|
||||
assert "Dystopian" in response.data
|
||||
# No duplicate genres
|
||||
assert response.data.count("Science Fiction") == 1
|
||||
|
||||
def test_authors_endpoint(self, auth_client, books):
|
||||
url = reverse("book-authors")
|
||||
response = auth_client.get(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert isinstance(response.data, list)
|
||||
assert "Frank Herbert" in response.data
|
||||
assert "J.R.R. Tolkien" in response.data
|
||||
|
||||
def test_genres_requires_auth(self, api_client, books):
|
||||
url = reverse("book-genres")
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
def test_authors_requires_auth(self, api_client, books):
|
||||
url = reverse("book-authors")
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detail view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBookDetail:
|
||||
"""Verify the book detail endpoint."""
|
||||
|
||||
def test_retrieve_book(self, auth_client, books):
|
||||
book = books[0]
|
||||
url = reverse("book-detail", kwargs={"pk": book.pk})
|
||||
response = auth_client.get(url)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["title"] == "Dune"
|
||||
assert response.data["author"] == "Frank Herbert"
|
||||
assert response.data["description"] == "A desert planet saga."
|
||||
assert response.data["total_pages"] == 688
|
||||
|
||||
def test_retrieve_nonexistent_returns_404(self, auth_client, books):
|
||||
url = reverse("book-detail", kwargs={"pk": 99999})
|
||||
response = auth_client.get(url)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_pagination(self, auth_client, books):
|
||||
"""List with small page size should paginate."""
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(f"{url}?page_size=2")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "count" in response.data
|
||||
assert "results" in response.data
|
||||
assert response.data["count"] == 4
|
||||
|
||||
def test_ordering(self, auth_client, books):
|
||||
url = reverse("book-list")
|
||||
response = auth_client.get(url, {"ordering": "title"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in response.data["results"]]
|
||||
assert titles == sorted(titles)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.books.views import BookViewSet, EBookViewSet, book_reading_settings_view
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"ebooks", EBookViewSet, basename="ebook")
|
||||
router.register(r"", BookViewSet, basename="book")
|
||||
|
||||
urlpatterns = [
|
||||
path("settings/", book_reading_settings_view, name="book-settings"),
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import QuerySet, Q
|
||||
from django.http import FileResponse
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import parsers, permissions, status, viewsets
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress
|
||||
from apps.books.serializers import (
|
||||
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
|
||||
BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
|
||||
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
|
||||
ReadingProgressSerializer, StorageSummarySerializer,
|
||||
)
|
||||
from apps.reader.models import ReadingSettings
|
||||
from apps.books.services.ebook_metadata import subjects_from_ebook
|
||||
from apps.books.services.metadata import enrich_ebook_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookViewSet(viewsets.ModelViewSet):
|
||||
queryset = Book.objects.all()
|
||||
permission_classes = [IsAuthenticated]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["author", "genre", "reading_status"]
|
||||
search_fields = ["title", "author", "genre"]
|
||||
ordering_fields = ["title", "author", "genre", "created_at"]
|
||||
ordering = ["title"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "retrieve":
|
||||
return BookDetailSerializer
|
||||
if self.action == "list":
|
||||
return BookListSerializer
|
||||
return BookSerializer
|
||||
|
||||
def get_queryset(self) -> QuerySet[Book]:
|
||||
qs = super().get_queryset()
|
||||
query = self.request.query_params.get("q", "").strip()
|
||||
if query:
|
||||
qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query))
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
|
||||
def genres(self, request: Request) -> Response:
|
||||
genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre")
|
||||
return Response([g for g in genre_list if g])
|
||||
|
||||
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
|
||||
def authors(self, request: Request) -> Response:
|
||||
author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author")
|
||||
return Response([a for a in author_list if a])
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def storage(self, request: Request) -> Response:
|
||||
"""Return storage usage summary for the current user."""
|
||||
download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook")
|
||||
total_size = sum(r.file_size for r in download_records)
|
||||
ebook_list = [
|
||||
{"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size}
|
||||
for r in download_records
|
||||
]
|
||||
serializer = StorageSummarySerializer(data={
|
||||
"total_downloads": download_records.count(),
|
||||
"total_size_bytes": total_size,
|
||||
"ebooks": ebook_list,
|
||||
})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class IsEBookOwner(permissions.BasePermission):
|
||||
def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool:
|
||||
return obj.user == request.user
|
||||
|
||||
|
||||
class EBookViewSet(viewsets.ModelViewSet):
|
||||
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
|
||||
permission_classes = [IsAuthenticated, IsEBookOwner]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return EBookUploadSerializer
|
||||
if self.action in ("list",):
|
||||
return EBookListSerializer
|
||||
if self.action in ("toc",):
|
||||
return BookChapterSerializer
|
||||
return EBookDetailSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
qs = EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user")
|
||||
query = self.request.query_params.get("q", "").strip()
|
||||
if query and self.action == "list":
|
||||
qs = qs.filter(
|
||||
Q(title__icontains=query) | Q(author__icontains=query),
|
||||
)
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def genres(self, request: Request) -> Response:
|
||||
genre_set: set[str] = set()
|
||||
for ebook in self.get_queryset():
|
||||
genre_set.update(subjects_from_ebook(ebook))
|
||||
return Response(sorted(genre_set))
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def authors(self, request: Request) -> Response:
|
||||
author_list = (
|
||||
self.get_queryset()
|
||||
.values_list("author", flat=True)
|
||||
.distinct()
|
||||
.order_by("author")
|
||||
)
|
||||
return Response([a for a in author_list if a])
|
||||
|
||||
@action(detail=True, methods=["get", "patch"])
|
||||
def progress(self, request: Request, pk: int | None = None) -> Response:
|
||||
ebook = self.get_object()
|
||||
progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook)
|
||||
if request.method == "GET":
|
||||
serializer = ReadingProgressSerializer(progress_obj)
|
||||
return Response(serializer.data)
|
||||
serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="enrich-metadata")
|
||||
def enrich_metadata(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Re-fetch Open Library metadata and cover for this ebook."""
|
||||
ebook = self.get_object()
|
||||
try:
|
||||
enrich_ebook_metadata(ebook)
|
||||
except Exception:
|
||||
logger.exception("Manual metadata enrichment failed for ebook %s", ebook.id)
|
||||
return Response(
|
||||
{"error": "Metadata enrichment failed."},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
ebook.refresh_from_db()
|
||||
serializer = EBookDetailSerializer(ebook, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def process(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
|
||||
ebook = self.get_object()
|
||||
if not ebook.file:
|
||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
from apps.books.services.process_ebook import apply_processing_to_ebook
|
||||
|
||||
result = apply_processing_to_ebook(ebook)
|
||||
return Response(result)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to process ebook %s", ebook.id)
|
||||
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
|
||||
"""Stream the ebook file for authenticated in-browser reading."""
|
||||
ebook = self.get_object()
|
||||
content_types = {
|
||||
"epub": "application/epub+zip",
|
||||
"pdf": "application/pdf",
|
||||
}
|
||||
content_type = content_types.get(ebook.format)
|
||||
if not content_type:
|
||||
return Response(
|
||||
{"error": "Unsupported format for in-browser reading."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not ebook.file:
|
||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return FileResponse(
|
||||
ebook.file.open("rb"),
|
||||
content_type=content_type,
|
||||
filename=ebook.filename(),
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def toc(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Return hierarchical table of contents."""
|
||||
ebook = self.get_object()
|
||||
chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
|
||||
serializer = BookChapterSerializer(chapters, many=True)
|
||||
return Response({
|
||||
"chapters": serializer.data,
|
||||
"format": ebook.format,
|
||||
"page_count": ebook.page_count,
|
||||
})
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def content(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Return paginated content for a given page number.
|
||||
|
||||
Query params:
|
||||
page (int): page/chapter index to fetch (1-indexed, default: 1)
|
||||
"""
|
||||
ebook = self.get_object()
|
||||
page = max(1, int(request.query_params.get("page", 1)))
|
||||
|
||||
chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook"))
|
||||
total_pages = len(chapters) or ebook.page_count or 1
|
||||
|
||||
chapter: BookChapter | None = None
|
||||
chapter_title = ""
|
||||
content_html = ""
|
||||
|
||||
if chapters and 0 <= page - 1 < len(chapters):
|
||||
ch = chapters[page - 1]
|
||||
chapter_title = ch.title
|
||||
content_html = _fetch_chapter_content(ebook, ch)
|
||||
|
||||
serializer = EBookContentSerializer(data={
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"content": content_html,
|
||||
"chapter_title": chapter_title,
|
||||
"format": ebook.format,
|
||||
})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def download(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Track download of an e-book. Creates a DownloadRecord and returns file info."""
|
||||
ebook = self.get_object()
|
||||
if not ebook.file:
|
||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
download, created = DownloadRecord.objects.get_or_create(
|
||||
user=request.user,
|
||||
ebook=ebook,
|
||||
defaults={"file_size": ebook.file.size if ebook.file else 0},
|
||||
)
|
||||
if not created:
|
||||
download.file_size = ebook.file.size if ebook.file else 0
|
||||
download.save(update_fields=["file_size"])
|
||||
|
||||
serializer = DownloadRecordSerializer(download, context={"request": request})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def downloads(self, request: Request) -> Response:
|
||||
"""List all e-books the current user has downloaded."""
|
||||
records = DownloadRecord.objects.filter(user=request.user).select_related(
|
||||
"ebook", "ebook__reading_progress"
|
||||
).prefetch_related("ebook__chapters")
|
||||
page = self.paginate_queryset(records)
|
||||
if page is not None:
|
||||
serializer = DownloadRecordSerializer(page, many=True, context={"request": request})
|
||||
return self.get_paginated_response(serializer.data)
|
||||
serializer = DownloadRecordSerializer(records, many=True, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["delete"], url_path="downloads/(?P<download_pk>[^/.]+)")
|
||||
def delete_download(self, request: Request, download_pk: str | None = None) -> Response:
|
||||
"""Delete a download record."""
|
||||
try:
|
||||
download = DownloadRecord.objects.get(pk=download_pk, user=request.user)
|
||||
except DownloadRecord.DoesNotExist:
|
||||
return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
download.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
|
||||
"""Fetch HTML content for a chapter from the e-book file."""
|
||||
if ebook.format == "epub":
|
||||
return _fetch_epub_chapter_content(ebook.file.path, chapter)
|
||||
return ""
|
||||
|
||||
|
||||
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
|
||||
"""Extract HTML content of a specific EPUB chapter by href."""
|
||||
try:
|
||||
from ebooklib import epub
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
try:
|
||||
book = epub.read_epub(file_path)
|
||||
href = chapter.href or ""
|
||||
# Find the item by href
|
||||
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")
|
||||
# Clean up — remove body/html/head wrappers, keep inner content
|
||||
body = soup.find("body")
|
||||
if body:
|
||||
return str(body)
|
||||
return str(soup)
|
||||
return ""
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
|
||||
return ""
|
||||
|
||||
|
||||
@api_view(["GET", "PATCH"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def book_reading_settings_view(request: Request) -> Response:
|
||||
"""Get or update reading settings via the books API contract."""
|
||||
settings, _created = ReadingSettings.objects.get_or_create(user=request.user)
|
||||
|
||||
if request.method == "GET":
|
||||
serializer = BookReadingSettingsSerializer(settings)
|
||||
return Response(serializer.data)
|
||||
|
||||
serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
@@ -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"])
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReaderConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.reader"
|
||||
verbose_name = "Reader Settings"
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.1.7 on 2026-05-29 06:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('users', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReadingSettings',
|
||||
fields=[
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='reading_settings', serialize=False, to=settings.AUTH_USER_MODEL)),
|
||||
('font_family', models.CharField(choices=[('sans-serif', 'Sans-serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='serif', max_length=32)),
|
||||
('font_size', models.PositiveSmallIntegerField(default=18)),
|
||||
('line_height', models.FloatField(default=1.6)),
|
||||
('margin_width', models.PositiveSmallIntegerField(default=16)),
|
||||
('background_color', models.CharField(default='#f5f0eb', max_length=7)),
|
||||
('text_color', models.CharField(default='#1a1a1a', max_length=7)),
|
||||
('brightness', models.PositiveSmallIntegerField(default=100)),
|
||||
('orientation_lock', models.CharField(choices=[('auto', 'Auto'), ('portrait', 'Portrait'), ('landscape', 'Landscape')], default='auto', max_length=16)),
|
||||
('theme', models.CharField(choices=[('sepia', 'Sepia'), ('dark', 'Dark'), ('light', 'Light'), ('paper', 'Paper')], default='sepia', max_length=32)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Reading Settings',
|
||||
'verbose_name_plural': 'Reading Settings',
|
||||
'db_table': 'reader_reading_settings',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ReadingSettings(models.Model):
|
||||
"""Per-user reading preferences for the e-book reader view."""
|
||||
|
||||
THEME_CHOICES = [
|
||||
("sepia", "Sepia"),
|
||||
("dark", "Dark"),
|
||||
("light", "Light"),
|
||||
("paper", "Paper"),
|
||||
]
|
||||
|
||||
FONT_CHOICES = [
|
||||
("sans-serif", "Sans-serif"),
|
||||
("serif", "Serif"),
|
||||
("monospace", "Monospace"),
|
||||
]
|
||||
|
||||
ORIENTATION_CHOICES = [
|
||||
("auto", "Auto"),
|
||||
("portrait", "Portrait"),
|
||||
("landscape", "Landscape"),
|
||||
]
|
||||
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reading_settings",
|
||||
primary_key=True,
|
||||
)
|
||||
font_family = models.CharField(max_length=32, choices=FONT_CHOICES, default="serif")
|
||||
font_size = models.PositiveSmallIntegerField(default=18)
|
||||
line_height = models.FloatField(default=1.6)
|
||||
margin_width = models.PositiveSmallIntegerField(default=16)
|
||||
background_color = models.CharField(max_length=7, default="#f5f0eb")
|
||||
text_color = models.CharField(max_length=7, default="#1a1a1a")
|
||||
brightness = models.PositiveSmallIntegerField(default=100)
|
||||
orientation_lock = models.CharField(
|
||||
max_length=16, choices=ORIENTATION_CHOICES, default="auto"
|
||||
)
|
||||
theme = models.CharField(max_length=32, choices=THEME_CHOICES, default="sepia")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "reader_reading_settings"
|
||||
verbose_name = "Reading Settings"
|
||||
verbose_name_plural = "Reading Settings"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} — {self.theme} ({self.font_size}px)"
|
||||
@@ -0,0 +1,63 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.reader.models import ReadingSettings
|
||||
|
||||
# Theme presets mapped to colors
|
||||
THEME_COLORS = {
|
||||
"sepia": {"background_color": "#f5f0eb", "text_color": "#1a1a1a"},
|
||||
"dark": {"background_color": "#1a1a2e", "text_color": "#e0e0e0"},
|
||||
"light": {"background_color": "#ffffff", "text_color": "#1a1a1a"},
|
||||
"paper": {"background_color": "#e8e0d4", "text_color": "#2c2c2c"},
|
||||
}
|
||||
|
||||
|
||||
class ReadingSettingsSerializer(serializers.ModelSerializer):
|
||||
"""Serialize ReadingSettings for the current user."""
|
||||
|
||||
class Meta:
|
||||
model = ReadingSettings
|
||||
fields = [
|
||||
"font_family",
|
||||
"font_size",
|
||||
"line_height",
|
||||
"margin_width",
|
||||
"background_color",
|
||||
"text_color",
|
||||
"brightness",
|
||||
"orientation_lock",
|
||||
"theme",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["created_at", "updated_at"]
|
||||
|
||||
def validate_font_size(self, value: int) -> int:
|
||||
if value < 12 or value > 32:
|
||||
raise serializers.ValidationError("Font size must be between 12 and 32.")
|
||||
return value
|
||||
|
||||
def validate_line_height(self, value: float) -> float:
|
||||
if value < 1.2 or value > 2.0:
|
||||
raise serializers.ValidationError("Line height must be between 1.2 and 2.0.")
|
||||
return value
|
||||
|
||||
def validate_margin_width(self, value: int) -> int:
|
||||
if value < 8 or value > 48:
|
||||
raise serializers.ValidationError("Margin width must be between 8 and 48.")
|
||||
return value
|
||||
|
||||
def validate_brightness(self, value: int) -> int:
|
||||
if value < 0 or value > 100:
|
||||
raise serializers.ValidationError("Brightness must be between 0 and 100.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Sync theme colors when theme changes, unless explicit colors provided."""
|
||||
theme = attrs.get("theme")
|
||||
if theme and theme in THEME_COLORS:
|
||||
# Only auto-set colors if not explicitly provided
|
||||
if "background_color" not in attrs:
|
||||
attrs["background_color"] = THEME_COLORS[theme]["background_color"]
|
||||
if "text_color" not in attrs:
|
||||
attrs["text_color"] = THEME_COLORS[theme]["text_color"]
|
||||
return attrs
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from apps.reader.views import reading_settings_view
|
||||
|
||||
urlpatterns = [
|
||||
path("settings/", reading_settings_view, name="reading-settings"),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.reader.models import ReadingSettings
|
||||
from apps.reader.serializers import ReadingSettingsSerializer
|
||||
|
||||
|
||||
@api_view(["GET", "PUT", "PATCH"])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def reading_settings_view(request: Request) -> Response:
|
||||
"""Get or update the current user's reading settings.
|
||||
|
||||
GET → return existing settings (auto-create defaults if missing)
|
||||
PUT → create or fully replace settings
|
||||
PATCH → partial update
|
||||
"""
|
||||
user = request.user
|
||||
settings, created = ReadingSettings.objects.get_or_create(user=user)
|
||||
|
||||
if request.method == "GET":
|
||||
serializer = ReadingSettingsSerializer(settings)
|
||||
return Response(serializer.data)
|
||||
|
||||
if request.method == "PUT":
|
||||
serializer = ReadingSettingsSerializer(settings, data=request.data)
|
||||
elif request.method == "PATCH":
|
||||
serializer = ReadingSettingsSerializer(settings, data=request.data, partial=True)
|
||||
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
"""Admin config for the custom User model."""
|
||||
list_display = ("email", "username", "is_staff", "is_active", "date_joined")
|
||||
search_fields = ("email", "username")
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.users"
|
||||
label = "users"
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.1.7 on 2026-06-03 22:26
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User',
|
||||
'verbose_name_plural': 'Users',
|
||||
'db_table': 'users_user',
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Custom user model. Uses email as the unique identifier field."""
|
||||
|
||||
class Meta:
|
||||
db_table = "users_user"
|
||||
verbose_name = "User"
|
||||
verbose_name_plural = "Users"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.email or self.username
|
||||
@@ -0,0 +1,93 @@
|
||||
import re
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from rest_framework import serializers
|
||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _derive_username(email: str) -> str:
|
||||
local = email.split("@", 1)[0]
|
||||
candidate = re.sub(r"[^\w.@+-]", "_", local).strip("._")
|
||||
return candidate[:150] if candidate else "user"
|
||||
|
||||
|
||||
def _unique_username(base: str) -> str:
|
||||
username = base[:150]
|
||||
if not User.objects.filter(username=username).exists():
|
||||
return username
|
||||
suffix = 1
|
||||
while User.objects.filter(username=f"{username[:140]}_{suffix}").exists():
|
||||
suffix += 1
|
||||
return f"{username[:140]}_{suffix}"
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField(write_only=True, min_length=8)
|
||||
|
||||
def validate_email(self, value: str) -> str:
|
||||
email = value.lower()
|
||||
if User.objects.filter(email__iexact=email).exists():
|
||||
raise serializers.ValidationError("A user with this email already exists.")
|
||||
return email
|
||||
|
||||
def validate_password(self, value: str) -> str:
|
||||
validate_password(value)
|
||||
return value
|
||||
|
||||
def create(self, validated_data: dict) -> User:
|
||||
email = validated_data["email"]
|
||||
username = _unique_username(_derive_username(email))
|
||||
return User.objects.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
password=validated_data["password"],
|
||||
)
|
||||
|
||||
def to_representation(self, instance: User) -> dict:
|
||||
return {
|
||||
"id": instance.id,
|
||||
"email": instance.email,
|
||||
"username": instance.username,
|
||||
}
|
||||
|
||||
|
||||
class EmailTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields.pop(self.username_field, None)
|
||||
self.fields["email"] = serializers.EmailField(required=True)
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
email = attrs.get("email", "").lower()
|
||||
password = attrs.get("password")
|
||||
|
||||
try:
|
||||
user = User.objects.get(email__iexact=email)
|
||||
except User.DoesNotExist as exc:
|
||||
raise serializers.ValidationError(
|
||||
{"detail": "No active account found with the given credentials."}
|
||||
) from exc
|
||||
|
||||
if not user.check_password(password):
|
||||
raise serializers.ValidationError(
|
||||
{"detail": "No active account found with the given credentials."}
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise serializers.ValidationError({"detail": "User account is disabled."})
|
||||
|
||||
refresh = self.get_token(user)
|
||||
return {
|
||||
"refresh": str(refresh),
|
||||
"access": str(refresh.access_token),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_token(cls, user: User) -> object:
|
||||
token = super().get_token(user)
|
||||
token["email"] = user.email
|
||||
return token
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
|
||||
from apps.users.views import EmailTokenObtainPairView, RegisterView
|
||||
|
||||
urlpatterns = [
|
||||
path("register/", RegisterView.as_view(), name="register"),
|
||||
path("token/", EmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("login/", EmailTokenObtainPairView.as_view(), name="login"),
|
||||
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
from rest_framework import generics
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
|
||||
from apps.users.serializers import EmailTokenObtainPairSerializer, RegisterSerializer
|
||||
|
||||
|
||||
class RegisterView(generics.CreateAPIView):
|
||||
serializer_class = RegisterSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
|
||||
class EmailTokenObtainPairView(TokenObtainPairView):
|
||||
serializer_class = EmailTokenObtainPairSerializer
|
||||
@@ -1,16 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from books.models import Book
|
||||
|
||||
|
||||
@admin.register(Book)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for the Book model."""
|
||||
|
||||
list_display = ["title", "author", "genre", "reading_status", "reading_progress", "owner", "updated_at"]
|
||||
list_filter = ["reading_status", "genre"]
|
||||
search_fields = ["title", "author"]
|
||||
readonly_fields = ["reading_progress", "created_at", "updated_at"]
|
||||
raw_id_fields = ["owner"]
|
||||
@@ -1,5 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BooksConfig(AppConfig):
|
||||
name = 'books'
|
||||
@@ -1,41 +0,0 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-26 00:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Book',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=500, verbose_name='Title')),
|
||||
('author', models.CharField(max_length=500, verbose_name='Author')),
|
||||
('genre', models.CharField(blank=True, default='', max_length=200, verbose_name='Genre')),
|
||||
('description', models.TextField(blank=True, default='', verbose_name='Description')),
|
||||
('cover_image_url', models.URLField(blank=True, default='', verbose_name='Cover Image URL')),
|
||||
('isbn', models.CharField(blank=True, default='', max_length=20, verbose_name='ISBN')),
|
||||
('total_pages', models.PositiveIntegerField(default=0, verbose_name='Total Pages')),
|
||||
('current_page', models.PositiveIntegerField(default=0, verbose_name='Current Page')),
|
||||
('reading_status', models.CharField(choices=[('not_started', 'Not Started'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], default='not_started', max_length=20, verbose_name='Reading Status')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='books', to=settings.AUTH_USER_MODEL, verbose_name='Owner')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Book',
|
||||
'verbose_name_plural': 'Books',
|
||||
'ordering': ['-updated_at'],
|
||||
'indexes': [models.Index(fields=['owner', 'reading_status'], name='books_book_owner_i_424622_idx'), models.Index(fields=['owner', 'title'], name='books_book_owner_i_350794_idx'), models.Index(fields=['owner', 'author'], name='books_book_owner_i_f90626_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,66 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ReadingStatus(models.TextChoices):
|
||||
"""Enumeration of possible reading statuses for a book."""
|
||||
|
||||
NOT_STARTED = "not_started", _("Not Started")
|
||||
READING = "reading", _("Reading")
|
||||
FINISHED = "finished", _("Finished")
|
||||
DNF = "dnf", _("Did Not Finish")
|
||||
|
||||
|
||||
class Book(models.Model):
|
||||
"""Represents a book in the user's library."""
|
||||
|
||||
title = models.CharField(max_length=500, verbose_name=_("Title"))
|
||||
author = models.CharField(max_length=500, verbose_name=_("Author"))
|
||||
genre = models.CharField(max_length=200, blank=True, default="", verbose_name=_("Genre"))
|
||||
description = models.TextField(blank=True, default="", verbose_name=_("Description"))
|
||||
cover_image_url = models.URLField(blank=True, default="", verbose_name=_("Cover Image URL"))
|
||||
isbn = models.CharField(max_length=20, blank=True, default="", verbose_name=_("ISBN"))
|
||||
total_pages = models.PositiveIntegerField(default=0, verbose_name=_("Total Pages"))
|
||||
current_page = models.PositiveIntegerField(default=0, verbose_name=_("Current Page"))
|
||||
reading_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=ReadingStatus.choices,
|
||||
default=ReadingStatus.NOT_STARTED,
|
||||
verbose_name=_("Reading Status"),
|
||||
)
|
||||
owner = models.ForeignKey(
|
||||
"auth.User",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="books",
|
||||
verbose_name=_("Owner"),
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
verbose_name = _("Book")
|
||||
verbose_name_plural = _("Books")
|
||||
indexes = [
|
||||
models.Index(fields=["owner", "reading_status"]),
|
||||
models.Index(fields=["owner", "title"]),
|
||||
models.Index(fields=["owner", "author"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} by {self.author}"
|
||||
|
||||
@property
|
||||
def reading_progress(self) -> float:
|
||||
"""Calculate reading progress as a percentage (0.0 - 100.0)."""
|
||||
if self.total_pages == 0:
|
||||
return 0.0
|
||||
return round((self.current_page / self.total_pages) * 100, 1)
|
||||
|
||||
def mark_as_finished(self) -> None:
|
||||
"""Mark the book as finished, setting progress to 100%."""
|
||||
self.reading_status = ReadingStatus.FINISHED
|
||||
self.current_page = self.total_pages
|
||||
self.save(update_fields=["reading_status", "current_page", "updated_at"])
|
||||
@@ -1,95 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from books.models import Book, ReadingStatus
|
||||
|
||||
|
||||
class BookListSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight serializer for list views — avoids heavy field serialization."""
|
||||
|
||||
reading_progress = serializers.FloatField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"author",
|
||||
"genre",
|
||||
"reading_status",
|
||||
"reading_progress",
|
||||
"cover_image_url",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class BookDetailSerializer(serializers.ModelSerializer):
|
||||
"""Full serializer for book detail views including all metadata."""
|
||||
|
||||
reading_progress = serializers.FloatField(read_only=True)
|
||||
owner = serializers.ReadOnlyField(source="owner.username")
|
||||
|
||||
class Meta:
|
||||
model = Book
|
||||
fields = [
|
||||
"id",
|
||||
"title",
|
||||
"author",
|
||||
"genre",
|
||||
"description",
|
||||
"cover_image_url",
|
||||
"isbn",
|
||||
"total_pages",
|
||||
"current_page",
|
||||
"reading_status",
|
||||
"reading_progress",
|
||||
"owner",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["owner", "created_at", "updated_at", "reading_progress"]
|
||||
|
||||
def validate_title(self, value: str) -> str:
|
||||
"""Ensure title is not just whitespace."""
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
msg = "Title cannot be empty."
|
||||
raise serializers.ValidationError(msg)
|
||||
return stripped
|
||||
|
||||
def validate_author(self, value: str) -> str:
|
||||
"""Ensure author is not just whitespace."""
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
msg = "Author cannot be empty."
|
||||
raise serializers.ValidationError(msg)
|
||||
return stripped
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
"""Business rules — handles both full creates and partial updates."""
|
||||
# Resolve effective values: use provided attrs, fall back to instance
|
||||
current_total = getattr(self.instance, "total_pages", None)
|
||||
current_current = getattr(self.instance, "current_page", None)
|
||||
total_pages = attrs.get("total_pages", current_total) or 0
|
||||
current_page = attrs.get("current_page", current_current) or 0
|
||||
reading_status = attrs.get("reading_status", None)
|
||||
|
||||
if current_page > total_pages > 0:
|
||||
msg = "Current page cannot exceed total pages."
|
||||
raise serializers.ValidationError({"current_page": msg})
|
||||
|
||||
if reading_status == ReadingStatus.FINISHED:
|
||||
if total_pages > 0:
|
||||
attrs["current_page"] = total_pages
|
||||
elif self.instance and self.instance.total_pages > 0:
|
||||
attrs["current_page"] = self.instance.total_pages
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class BookWriteSerializer(BookDetailSerializer):
|
||||
"""Alias for detail serializer — used for write operations with full validation."""
|
||||
|
||||
pass
|
||||
@@ -1,325 +0,0 @@
|
||||
"""Tests for the books app — API endpoints, models, serializers, and permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from books.models import Book, ReadingStatus
|
||||
|
||||
|
||||
class BookModelTests(TestCase):
|
||||
"""Tests for the Book model."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user(username="testuser", password="testpass123")
|
||||
self.book = Book.objects.create(
|
||||
title="Test Book",
|
||||
author="Test Author",
|
||||
total_pages=200,
|
||||
current_page=50,
|
||||
reading_status=ReadingStatus.READING,
|
||||
owner=self.user,
|
||||
)
|
||||
|
||||
def test_reading_progress_calculates_correctly(self) -> None:
|
||||
"""Reading progress should be (current_page / total_pages) * 100."""
|
||||
assert self.book.reading_progress == 25.0
|
||||
|
||||
def test_reading_progress_returns_zero_when_no_pages(self) -> None:
|
||||
"""When total_pages is 0, reading_progress should be 0.0."""
|
||||
book = Book.objects.create(
|
||||
title="No Pages",
|
||||
author="Author",
|
||||
total_pages=0,
|
||||
current_page=50,
|
||||
owner=self.user,
|
||||
)
|
||||
assert book.reading_progress == 0.0
|
||||
|
||||
def test_mark_as_finished_sets_progress_to_100(self) -> None:
|
||||
"""mark_as_finished should set status to FINISHED and progress to 100%."""
|
||||
self.book.mark_as_finished()
|
||||
self.book.refresh_from_db()
|
||||
assert self.book.reading_status == ReadingStatus.FINISHED
|
||||
assert self.book.current_page == self.book.total_pages
|
||||
|
||||
def test_mark_as_finished_with_zero_pages(self) -> None:
|
||||
"""mark_as_finished with total_pages=0 should set 0/0."""
|
||||
book = Book.objects.create(
|
||||
title="Empty",
|
||||
author="Author",
|
||||
total_pages=0,
|
||||
current_page=0,
|
||||
owner=self.user,
|
||||
)
|
||||
book.mark_as_finished()
|
||||
book.refresh_from_db()
|
||||
assert book.reading_status == ReadingStatus.FINISHED
|
||||
assert book.current_page == 0
|
||||
|
||||
def test_str_method(self) -> None:
|
||||
"""__str__ should return 'Title by Author'."""
|
||||
assert str(self.book) == "Test Book by Test Author"
|
||||
|
||||
|
||||
class BookAPITests(TestCase):
|
||||
"""Tests for the Book API endpoints."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create_user(username="testuser", password="testpass123")
|
||||
self.other_user = User.objects.create_user(username="other", password="testpass123")
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.book = Book.objects.create(
|
||||
title="My Book",
|
||||
author="My Author",
|
||||
genre="Fiction",
|
||||
description="A great book",
|
||||
total_pages=200,
|
||||
current_page=50,
|
||||
reading_status=ReadingStatus.READING,
|
||||
owner=self.user,
|
||||
)
|
||||
# Other user's book (should not be visible)
|
||||
Book.objects.create(
|
||||
title="Other Book",
|
||||
author="Other Author",
|
||||
total_pages=100,
|
||||
owner=self.other_user,
|
||||
)
|
||||
|
||||
# --- List ---
|
||||
|
||||
def test_list_books_returns_owned_books_only(self) -> None:
|
||||
"""GET /api/books/ should only return books owned by the current user."""
|
||||
resp = self.client.get("/api/books/")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["count"] == 1
|
||||
assert resp.data["results"][0]["title"] == "My Book"
|
||||
|
||||
def test_list_books_requires_authentication(self) -> None:
|
||||
"""GET /api/books/ without auth should return 403."""
|
||||
self.client.force_authenticate(user=None)
|
||||
resp = self.client.get("/api/books/")
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
# --- Create ---
|
||||
|
||||
def test_create_book_sets_owner(self) -> None:
|
||||
"""POST /api/books/ should create a book owned by the current user."""
|
||||
resp = self.client.post("/api/books/", {
|
||||
"title": "New Book",
|
||||
"author": "New Author",
|
||||
}, format="json")
|
||||
assert resp.status_code == status.HTTP_201_CREATED
|
||||
assert resp.data["owner"] == "testuser"
|
||||
assert Book.objects.filter(title="New Book", owner=self.user).exists()
|
||||
|
||||
def test_create_book_validates_required_fields(self) -> None:
|
||||
"""POST /api/books/ with missing title should fail."""
|
||||
resp = self.client.post("/api/books/", {
|
||||
"author": "Author Only",
|
||||
}, format="json")
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# --- Retrieve ---
|
||||
|
||||
def test_retrieve_book(self) -> None:
|
||||
"""GET /api/books/{id}/ should return full book details."""
|
||||
resp = self.client.get(f"/api/books/{self.book.id}/")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["title"] == "My Book"
|
||||
assert resp.data["reading_progress"] == 25.0
|
||||
assert resp.data["owner"] == "testuser"
|
||||
|
||||
def test_retrieve_other_users_book_returns_404(self) -> None:
|
||||
"""GET /api/books/{other_id}/ should return 404 for another user's book."""
|
||||
other_book = Book.objects.get(title="Other Book")
|
||||
resp = self.client.get(f"/api/books/{other_book.id}/")
|
||||
assert resp.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# --- Update ---
|
||||
|
||||
def test_update_book(self) -> None:
|
||||
"""PATCH /api/books/{id}/ should update book fields."""
|
||||
resp = self.client.patch(f"/api/books/{self.book.id}/", {
|
||||
"title": "Updated Title",
|
||||
"current_page": 100,
|
||||
}, format="json")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["title"] == "Updated Title"
|
||||
assert resp.data["reading_progress"] == 50.0
|
||||
|
||||
def test_update_current_page_exceeds_total(self) -> None:
|
||||
"""PATCH with current_page > total_pages should fail."""
|
||||
resp = self.client.patch(f"/api/books/{self.book.id}/", {
|
||||
"current_page": 999,
|
||||
}, format="json")
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
# --- Delete ---
|
||||
|
||||
def test_delete_book(self) -> None:
|
||||
"""DELETE /api/books/{id}/ should delete the book."""
|
||||
resp = self.client.delete(f"/api/books/{self.book.id}/")
|
||||
assert resp.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Book.objects.filter(id=self.book.id).exists()
|
||||
|
||||
# --- Mark Finished ---
|
||||
|
||||
def test_mark_finished_sets_100_percent(self) -> None:
|
||||
"""POST /api/books/{id}/mark_finished/ should set progress to 100%."""
|
||||
resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["reading_status"] == ReadingStatus.FINISHED
|
||||
assert resp.data["reading_progress"] == 100.0
|
||||
|
||||
def test_mark_finished_idempotent(self) -> None:
|
||||
"""Calling mark_finished twice should be safe."""
|
||||
self.client.post(f"/api/books/{self.book.id}/mark_finished/")
|
||||
resp = self.client.post(f"/api/books/{self.book.id}/mark_finished/")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_mark_finished_other_users_book(self) -> None:
|
||||
"""POST mark_finished on another user's book should return 404."""
|
||||
other_book = Book.objects.get(title="Other Book")
|
||||
resp = self.client.post(f"/api/books/{other_book.id}/mark_finished/")
|
||||
assert resp.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# --- Stats ---
|
||||
|
||||
def test_stats_returns_counts(self) -> None:
|
||||
"""GET /api/books/stats/ should return aggregate counts."""
|
||||
resp = self.client.get("/api/books/stats/")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["total_books"] == 1
|
||||
assert resp.data["finished"] == 0
|
||||
assert resp.data["reading"] == 1
|
||||
assert resp.data["not_started"] == 0
|
||||
|
||||
# --- Filtering & Sorting ---
|
||||
|
||||
def test_filter_by_reading_status(self) -> None:
|
||||
"""GET /api/books/?reading_status=reading should filter correctly."""
|
||||
resp = self.client.get("/api/books/", {"reading_status": "reading"})
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["count"] == 1
|
||||
|
||||
resp = self.client.get("/api/books/", {"reading_status": "finished"})
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["count"] == 0
|
||||
|
||||
def test_search_by_title(self) -> None:
|
||||
"""GET /api/books/?search=My should filter by title."""
|
||||
resp = self.client.get("/api/books/", {"search": "My"})
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["count"] == 1
|
||||
|
||||
resp = self.client.get("/api/books/", {"search": "Nonexistent"})
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["count"] == 0
|
||||
|
||||
def test_search_by_author(self) -> None:
|
||||
"""GET /api/books/?search=My should filter by author."""
|
||||
resp = self.client.get("/api/books/", {"search": "My Author"})
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.data["count"] == 1
|
||||
|
||||
def test_sort_by_title(self) -> None:
|
||||
"""GET /api/books/?sort_by=title should sort alphabetically."""
|
||||
Book.objects.create(title="Aardvark", author="Author", owner=self.user)
|
||||
Book.objects.create(title="Zebra", author="Author", owner=self.user)
|
||||
resp = self.client.get("/api/books/", {"sort_by": "title"})
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
titles = [b["title"] for b in resp.data["results"]]
|
||||
assert titles == sorted(titles)
|
||||
|
||||
def test_sort_by_author_desc(self) -> None:
|
||||
"""GET /api/books/?sort_by=-author should sort by author desc."""
|
||||
resp = self.client.get("/api/books/", {"sort_by": "-author"})
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
|
||||
# --- Pagination ---
|
||||
|
||||
def test_pagination_default_page_size(self) -> None:
|
||||
"""Books should be paginated with default page size."""
|
||||
for i in range(25):
|
||||
Book.objects.create(
|
||||
title=f"Book {i}",
|
||||
author="Author",
|
||||
owner=self.user,
|
||||
)
|
||||
resp = self.client.get("/api/books/")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert len(resp.data["results"]) == 20 # PAGE_SIZE = 20
|
||||
assert resp.data["count"] == 26 # 1 original + 25 new
|
||||
|
||||
def test_pagination_second_page(self) -> None:
|
||||
"""GET /api/books/?page=2 should return remaining items."""
|
||||
for i in range(25):
|
||||
Book.objects.create(
|
||||
title=f"Book {i}",
|
||||
author="Author",
|
||||
owner=self.user,
|
||||
)
|
||||
resp = self.client.get("/api/books/", {"page": 2})
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert len(resp.data["results"]) == 6
|
||||
|
||||
|
||||
class BookSerializerTests(TestCase):
|
||||
"""Tests for serializers — validation rules."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_user(username="testuser", password="testpass123")
|
||||
|
||||
def test_current_page_cannot_exceed_total(self) -> None:
|
||||
"""Serializer should reject current_page > total_pages."""
|
||||
from books.serializers import BookDetailSerializer
|
||||
data = {
|
||||
"title": "Test",
|
||||
"author": "Author",
|
||||
"total_pages": 100,
|
||||
"current_page": 150,
|
||||
}
|
||||
serializer = BookDetailSerializer(data=data)
|
||||
assert not serializer.is_valid()
|
||||
assert "current_page" in serializer.errors
|
||||
|
||||
def test_title_cannot_be_blank(self) -> None:
|
||||
"""Serializer should reject blank title."""
|
||||
from books.serializers import BookDetailSerializer
|
||||
data = {
|
||||
"title": " ",
|
||||
"author": "Author",
|
||||
}
|
||||
serializer = BookDetailSerializer(data=data)
|
||||
assert not serializer.is_valid()
|
||||
assert "title" in serializer.errors
|
||||
|
||||
def test_author_cannot_be_blank(self) -> None:
|
||||
"""Serializer should reject blank author."""
|
||||
from books.serializers import BookDetailSerializer
|
||||
data = {
|
||||
"title": "Test",
|
||||
"author": "",
|
||||
}
|
||||
serializer = BookDetailSerializer(data=data)
|
||||
assert not serializer.is_valid()
|
||||
assert "author" in serializer.errors
|
||||
|
||||
def test_list_serializer_has_minimal_fields(self) -> None:
|
||||
"""BookListSerializer should exclude sensitive/fluff fields."""
|
||||
from books.serializers import BookListSerializer
|
||||
serializer = BookListSerializer()
|
||||
fields = set(serializer.fields.keys())
|
||||
assert "id" in fields
|
||||
assert "title" in fields
|
||||
assert "author" in fields
|
||||
assert "reading_progress" in fields
|
||||
assert "description" not in fields
|
||||
assert "isbn" not in fields
|
||||
@@ -1,13 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from books.views import BookViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"books", BookViewSet, basename="book")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
@@ -1,91 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import QuerySet
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from books.models import Book, ReadingStatus
|
||||
from books.serializers import BookDetailSerializer, BookListSerializer
|
||||
|
||||
|
||||
class BookViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing books in the user's library.
|
||||
|
||||
Provides:
|
||||
- list / retrieve / create / update / partial_update / destroy
|
||||
- `mark_finished` action to set a book as 100% complete
|
||||
- `stats` action for library overview counts
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self) -> type:
|
||||
if self.action == "list":
|
||||
return BookListSerializer
|
||||
return BookDetailSerializer
|
||||
|
||||
def get_queryset(self) -> QuerySet[Book]:
|
||||
"""Return books owned by the current user with optimised queries."""
|
||||
qs = Book.objects.filter(owner=self.request.user).select_related("owner")
|
||||
|
||||
# Sorting
|
||||
sort_by = self.request.query_params.get("sort_by", "-updated_at")
|
||||
allowed_sorts = {
|
||||
"title": "title",
|
||||
"-title": "-title",
|
||||
"author": "author",
|
||||
"-author": "-author",
|
||||
"created_at": "created_at",
|
||||
"-created_at": "-created_at",
|
||||
"updated_at": "updated_at",
|
||||
"-updated_at": "-updated_at",
|
||||
"reading_progress": "current_page", # approximate sort by pages read
|
||||
"-reading_progress": "-current_page",
|
||||
}
|
||||
if sort_by in allowed_sorts:
|
||||
qs = qs.order_by(allowed_sorts[sort_by])
|
||||
|
||||
# Filtering
|
||||
status_filter = self.request.query_params.get("reading_status", None)
|
||||
if status_filter in ReadingStatus.values:
|
||||
qs = qs.filter(reading_status=status_filter)
|
||||
|
||||
search = self.request.query_params.get("search", "").strip()
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
models.Q(title__icontains=search) | models.Q(author__icontains=search)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
def perform_create(self, serializer: BookDetailSerializer) -> None:
|
||||
"""Set the owner to the current user on creation."""
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def mark_finished(self, request: Request, pk: int | None = None) -> Response:
|
||||
"""Mark a book as finished (100% progress)."""
|
||||
book: Book = self.get_object()
|
||||
book.mark_as_finished()
|
||||
serializer = self.get_serializer(book)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def stats(self, request: Request) -> Response:
|
||||
"""Return aggregate stats about the user's library."""
|
||||
qs = self.get_queryset()
|
||||
total = qs.count()
|
||||
finished = qs.filter(reading_status=ReadingStatus.FINISHED).count()
|
||||
reading = qs.filter(reading_status=ReadingStatus.READING).count()
|
||||
not_started = qs.filter(reading_status=ReadingStatus.NOT_STARTED).count()
|
||||
return Response({
|
||||
"total_books": total,
|
||||
"finished": finished,
|
||||
"reading": reading,
|
||||
"not_started": not_started,
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
ASGI config for config project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Django settings for cloud-reader backend.
|
||||
|
||||
Generated using Django 5.1. Customised with pydantic-settings integration.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build paths
|
||||
# ---------------------------------------------------------------------------
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security
|
||||
# ---------------------------------------------------------------------------
|
||||
SECRET_KEY = settings.DJANGO_SECRET_KEY
|
||||
DEBUG = settings.DJANGO_DEBUG
|
||||
ALLOWED_HOSTS = settings.DJANGO_ALLOWED_HOSTS
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application definition
|
||||
# ---------------------------------------------------------------------------
|
||||
INSTALLED_APPS = [
|
||||
# Django built-in
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Third-party
|
||||
"rest_framework",
|
||||
"rest_framework_simplejwt",
|
||||
"corsheaders",
|
||||
"django_filters",
|
||||
# Local apps
|
||||
"apps.users",
|
||||
"apps.books",
|
||||
"apps.groups",
|
||||
"apps.annotations",
|
||||
"apps.reader",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database
|
||||
# ---------------------------------------------------------------------------
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": settings.DB_NAME,
|
||||
"USER": settings.DB_USER,
|
||||
"PASSWORD": settings.DB_PASSWORD,
|
||||
"HOST": settings.DB_HOST,
|
||||
"PORT": settings.DB_PORT,
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DRF
|
||||
# ---------------------------------------------------------------------------
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
),
|
||||
"DEFAULT_PERMISSION_CLASSES": (
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
),
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 50,
|
||||
"DEFAULT_FILTER_BACKENDS": [
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
"rest_framework.filters.SearchFilter",
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SimpleJWT
|
||||
# ---------------------------------------------------------------------------
|
||||
from datetime import timedelta
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=settings.JWT_REFRESH_TOKEN_LIFETIME_DAYS),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CORS
|
||||
# ---------------------------------------------------------------------------
|
||||
CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# i18n
|
||||
# ---------------------------------------------------------------------------
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static / Media / Uploads
|
||||
# ---------------------------------------------------------------------------
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# Maximum upload size: 50MB
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default primary key
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
+36
-88
@@ -1,99 +1,47 @@
|
||||
"""
|
||||
Django settings for config project.
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.5.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings via pydantic-settings. Reads from env vars and .env."""
|
||||
|
||||
from config.settings_app import settings
|
||||
# Django
|
||||
DJANGO_SECRET_KEY: str = "django-insecure-change-me-in-production"
|
||||
DJANGO_DEBUG: bool = False
|
||||
DJANGO_ALLOWED_HOSTS: list[str] = ["*"]
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
# PostgreSQL
|
||||
DB_NAME: str = "cloud_reader"
|
||||
DB_USER: str = "postgres"
|
||||
DB_PASSWORD: str = "postgres"
|
||||
DB_HOST: str = "localhost"
|
||||
DB_PORT: int = 5432
|
||||
|
||||
SECRET_KEY = settings.SECRET_KEY
|
||||
DEBUG = settings.DEBUG
|
||||
ALLOWED_HOSTS = settings.ALLOWED_HOSTS
|
||||
# JWT
|
||||
JWT_ACCESS_TOKEN_LIFETIME_MINUTES: int = 60
|
||||
JWT_REFRESH_TOKEN_LIFETIME_DAYS: int = 7
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Third-party
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
# Local apps
|
||||
"books",
|
||||
]
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS: list[str] = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
# Open Library metadata enrichment
|
||||
OPENLIBRARY_ENABLED: bool = True
|
||||
OPENLIBRARY_PREFERRED_LANG: str = "es"
|
||||
OPENLIBRARY_FALLBACK_LANG: str = "en"
|
||||
OPENLIBRARY_TIMEOUT_SECONDS: float = 10.0
|
||||
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS: float = 15.0
|
||||
OPENLIBRARY_USER_AGENT: str = "CloudReader/1.0 (https://github.com/cloud-reader)"
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
return (
|
||||
f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}"
|
||||
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||
)
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS
|
||||
CORS_ALLOW_CREDENTIALS = settings.CORS_ALLOW_CREDENTIALS
|
||||
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"rest_framework.authentication.BasicAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
}
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
DEBUG: bool = True
|
||||
SECRET_KEY: str = "django-insecure-change-me-in-production"
|
||||
DATABASE_URL: str = "sqlite:///db.sqlite3"
|
||||
ALLOWED_HOSTS: list[str] = ["*"]
|
||||
CORS_ALLOWED_ORIGINS: list[str] = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
|
||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CR_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
+11
-2
@@ -1,8 +1,17 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api-auth/", include("rest_framework.urls")),
|
||||
path("api/", include("books.urls")),
|
||||
path("api/auth/", include("apps.users.urls")),
|
||||
path("api/books/", include("apps.books.urls")),
|
||||
path("api/groups/", include("apps.groups.urls")),
|
||||
path("api/annotations/", include("apps.annotations.urls")),
|
||||
path("api/reader/", include("apps.reader.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
from django.conf.urls.static import static
|
||||
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
+1
-10
@@ -1,16 +1,7 @@
|
||||
"""
|
||||
WSGI config for config project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
|
||||
|
||||
application = get_wsgi_application()
|
||||
Executable → Regular
+3
-2
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
@@ -18,5 +19,5 @@ def main():
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "backend"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"coverage==7.6.10",
|
||||
"django==5.1.7",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-filter==25.1",
|
||||
"djangorestframework==3.15.2",
|
||||
"djangorestframework-simplejwt==5.4.0",
|
||||
"gunicorn==23.0.0",
|
||||
"pillow>=11.0.0",
|
||||
"psycopg2-binary==2.9.10",
|
||||
"pydantic==2.10.5",
|
||||
"pydantic-settings==2.7.1",
|
||||
"pytest==8.3.4",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"python-dotenv==1.0.1",
|
||||
"httpx>=0.28.0",
|
||||
"ebooklib>=0.18",
|
||||
"beautifulsoup4>=4.12.0",
|
||||
"pypdf>=5.0.0",
|
||||
]
|
||||
+7
-2
@@ -1,3 +1,8 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = config.settings
|
||||
[tool:pytest]
|
||||
DJANGO_SETTINGS_MODULE = config.django
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
testpaths = apps
|
||||
|
||||
[coverage:run]
|
||||
source = apps
|
||||
omit = */tests/*,*/migrations/*,*/admin.py,*/apps.py
|
||||
@@ -1,6 +1,15 @@
|
||||
django>=5.0,<7.0
|
||||
djangorestframework>=3.15.0
|
||||
django-cors-headers>=4.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
pytest-django>=4.5.0
|
||||
Django==5.1.7
|
||||
djangorestframework==3.15.2
|
||||
djangorestframework-simplejwt==5.4.0
|
||||
django-filter==25.1
|
||||
django-cors-headers==4.6.0
|
||||
psycopg2-binary==2.9.10
|
||||
pydantic==2.10.5
|
||||
pydantic-settings==2.7.1
|
||||
python-dotenv==1.0.1
|
||||
gunicorn==23.0.0
|
||||
Pillow>=11.0.0
|
||||
pytest==8.3.4
|
||||
pytest-django==4.9.0
|
||||
pytest-cov==6.0.0
|
||||
coverage==7.6.10
|
||||
Generated
+673
@@ -0,0 +1,673 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backend"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "coverage" },
|
||||
{ name = "django" },
|
||||
{ name = "django-cors-headers" },
|
||||
{ name = "django-filter" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "djangorestframework-simplejwt" },
|
||||
{ name = "ebooklib" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pypdf" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
|
||||
{ name = "coverage", specifier = "==7.6.10" },
|
||||
{ name = "django", specifier = "==5.1.7" },
|
||||
{ name = "django-cors-headers", specifier = "==4.6.0" },
|
||||
{ name = "django-filter", specifier = "==25.1" },
|
||||
{ name = "djangorestframework", specifier = "==3.15.2" },
|
||||
{ name = "djangorestframework-simplejwt", specifier = "==5.4.0" },
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gunicorn", specifier = "==23.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.0" },
|
||||
{ name = "pillow", specifier = ">=11.0.0" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.10" },
|
||||
{ name = "pydantic", specifier = "==2.10.5" },
|
||||
{ name = "pydantic-settings", specifier = "==2.7.1" },
|
||||
{ name = "pypdf", specifier = ">=5.0.0" },
|
||||
{ name = "pytest", specifier = "==8.3.4" },
|
||||
{ name = "pytest-cov", specifier = "==6.0.0" },
|
||||
{ name = "pytest-django", specifier = "==4.9.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.0.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.5.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.6.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868, upload-time = "2024-12-26T16:59:18.734Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281, upload-time = "2024-12-26T16:57:42.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514, upload-time = "2024-12-26T16:57:45.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537, upload-time = "2024-12-26T16:57:48.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572, upload-time = "2024-12-26T16:57:51.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639, upload-time = "2024-12-26T16:57:53.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072, upload-time = "2024-12-26T16:57:56.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386, upload-time = "2024-12-26T16:57:57.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054, upload-time = "2024-12-26T16:57:58.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904, upload-time = "2024-12-26T16:58:00.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692, upload-time = "2024-12-26T16:58:02.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308, upload-time = "2024-12-26T16:58:04.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565, upload-time = "2024-12-26T16:58:06.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083, upload-time = "2024-12-26T16:58:10.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235, upload-time = "2024-12-26T16:58:12.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220, upload-time = "2024-12-26T16:58:15.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847, upload-time = "2024-12-26T16:58:17.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922, upload-time = "2024-12-26T16:58:20.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783, upload-time = "2024-12-26T16:58:23.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965, upload-time = "2024-12-26T16:58:26.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719, upload-time = "2024-12-26T16:58:28.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050, upload-time = "2024-12-26T16:58:31.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321, upload-time = "2024-12-26T16:58:34.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039, upload-time = "2024-12-26T16:58:36.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758, upload-time = "2024-12-26T16:58:39.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119, upload-time = "2024-12-26T16:58:41.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597, upload-time = "2024-12-26T16:58:42.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473, upload-time = "2024-12-26T16:58:44.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737, upload-time = "2024-12-26T16:58:45.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611, upload-time = "2024-12-26T16:58:47.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781, upload-time = "2024-12-26T16:58:50.822Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.1.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/57/11186e493ddc5a5e92cc7924a6363f7d4c2b645f7d7cb04a26a63f9bfb8b/Django-5.1.7.tar.gz", hash = "sha256:30de4ee43a98e5d3da36a9002f287ff400b43ca51791920bfb35f6917bfe041c", size = 10716510, upload-time = "2025-03-06T12:52:18.938Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/0f/7e042df3d462d39ae01b27a09ee76653692442bc3701fbfa6cb38e12889d/Django-5.1.7-py3-none-any.whl", hash = "sha256:1323617cb624add820cb9611cdcc788312d250824f92ca6048fda8625514af2b", size = 8276912, upload-time = "2025-03-06T12:52:12.784Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-cors-headers"
|
||||
version = "4.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/e5/3b67fc05b9c02b926411436dfc553829bc00843706ce7f99752433017f47/django_cors_headers-4.6.0.tar.gz", hash = "sha256:14d76b4b4c8d39375baeddd89e4f08899051eeaf177cb02a29bd6eae8cf63aa8", size = 20961, upload-time = "2024-10-29T10:38:15.281Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/73/689532cf164ab10ed1521d825ea156656520cec98886c8d2ac1ce8829220/django_cors_headers-4.6.0-py3-none-any.whl", hash = "sha256:8edbc0497e611c24d5150e0055d3b178c6534b8ed826fb6f53b21c63f5d48ba3", size = 12791, upload-time = "2024-10-29T10:38:13.784Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-filter"
|
||||
version = "25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/40/c702a6fe8cccac9bf426b55724ebdf57d10a132bae80a17691d0cf0b9bac/django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153", size = 143021, upload-time = "2025-02-14T16:30:53.238Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/a6/70dcd68537c434ba7cb9277d403c5c829caf04f35baf5eb9458be251e382/django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80", size = 94114, upload-time = "2025-02-14T16:30:50.435Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework"
|
||||
version = "3.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework-simplejwt"
|
||||
version = "5.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "pyjwt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/5f/1c130e823f734ba776c3925ad8e7c28ca1a59feb22d039b3810d1f8c0b34/djangorestframework_simplejwt-5.4.0.tar.gz", hash = "sha256:cccecce1a0e1a4a240fae80da73e5fc23055bababb8b67de88fa47cd36822320", size = 96648, upload-time = "2025-01-07T08:25:11.667Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/41/d6f67d24f46c7d8ee6dcb85c9abb94bad23140210bfb8c454641d278dfd2/djangorestframework_simplejwt-5.4.0-py3-none-any.whl", hash = "sha256:7aec953db9ed4163430c16d086eecb0f028f814ce6bba62b06c25919261e9077", size = 102316, upload-time = "2025-01-07T08:24:30.503Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ebooklib"
|
||||
version = "0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/85/322e8882a582d4b707220d1929cfb74c125f2ba513991edbce40dbc462de/ebooklib-0.20.tar.gz", hash = "sha256:35e2f9d7d39907be8d39ae2deb261b19848945903ae3dbb6577b187ead69e985", size = 127066, upload-time = "2025-10-26T20:56:20.968Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ee/aa015c5de8b0dc42a8e507eae8c2de5d1c0e068c896858fec6d502402ed6/ebooklib-0.20-py3-none-any.whl", hash = "sha256:fff5322517a37e31c972d27be7d982cc3928c16b3dcc5fd7e8f7c0f5d7bcf42b", size = 40995, upload-time = "2025-10-26T20:56:19.104Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "23.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.10.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287, upload-time = "2025-01-09T13:33:25.929Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426, upload-time = "2025-01-09T13:33:22.312Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.27.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920, upload-time = "2024-12-31T11:27:44.632Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718, upload-time = "2024-12-31T11:27:43.201Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.12.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/6d/20879428577c1e57ecd41b69dc86beabf43db9287ad2e702207f8b48c751/pypdf-6.12.2.tar.gz", hash = "sha256:111669eb6680c04495ae0c113a1476e3bf93a95761d23c7406b591c80a6490b1", size = 6468184, upload-time = "2026-05-26T13:31:26.911Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/44/fee070a16639d9869bb6a7e0f3a1b3946da1d66f32b9260b4d19cb90d7b2/pypdf-6.12.2-py3-none-any.whl", hash = "sha256:67b2699357a1f3f4c945940ea80826349ee507c9e2577724a14b4941982c104d", size = 343865, upload-time = "2026-05-26T13:31:25.068Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-django"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067, upload-time = "2024-09-02T15:49:18.407Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723, upload-time = "2024-09-02T15:49:17.127Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-cloud_reader}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d cloud_reader"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-True}
|
||||
DB_NAME: ${POSTGRES_DB:-cloud_reader}
|
||||
DB_USER: ${POSTGRES_USER:-postgres}
|
||||
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
DB_HOST: ${DB_HOST:-db}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./backend/media:/app/media
|
||||
command: >
|
||||
sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,117 @@
|
||||
# 010 — Open Library metadata on import
|
||||
|
||||
**Status:** Implemented
|
||||
**Created:** 2026-06-03
|
||||
|
||||
## Objective
|
||||
|
||||
After a user uploads an EPUB/PDF (`POST /api/books/ebooks/`), enrich the `EBook` with metadata and a cover from [Open Library](https://openlibrary.org/developers/api), using only **title** and **author** from the upload form. Prefer **Spanish** editions when available; fall back to English/any.
|
||||
|
||||
Upload must **never fail** if Open Library is down or no match is found.
|
||||
|
||||
## Trigger
|
||||
|
||||
- **Automatic:** `EBookUploadSerializer.create()` calls `enrich_ebook_metadata(ebook)` after file save.
|
||||
- **Manual:** `POST /api/books/ebooks/{id}/enrich-metadata/` re-runs enrichment (owner only).
|
||||
|
||||
## Open Library usage
|
||||
|
||||
### Search
|
||||
|
||||
`GET https://openlibrary.org/search.json`
|
||||
|
||||
| Param | Value |
|
||||
|-------|--------|
|
||||
| `title` | User-provided title |
|
||||
| `author` | User-provided author |
|
||||
| `lang` | `es` (primary) or `en` (fallback) |
|
||||
| `limit` | `5` |
|
||||
| `fields` | `key,title,author_name,cover_i,first_publish_year,subject,language,edition_key,number_of_pages_median,publisher` |
|
||||
|
||||
Primary pass also uses query filter `language:spa`. Fallback omits language filter.
|
||||
|
||||
### Covers
|
||||
|
||||
`GET https://covers.openlibrary.org/b/id/{cover_i}-L.jpg` — downloaded and stored on `EBook.cover_image`.
|
||||
|
||||
## Match scoring
|
||||
|
||||
| Score | Behavior |
|
||||
|-------|----------|
|
||||
| ≥ 0.8 | Apply OL title/author + cover + full metadata |
|
||||
| 0.6 – 0.8 | Metadata + cover only; keep user title/author |
|
||||
| < 0.6 | `match_status: not_found`; no field changes except metadata stub |
|
||||
|
||||
Author overlap + title similarity (normalized strings, `difflib.SequenceMatcher`). Prefer hits with `cover_i`.
|
||||
|
||||
## Data model
|
||||
|
||||
No migration. Uses existing fields on `EBook`:
|
||||
|
||||
- `metadata_json` — full enrichment payload (see below)
|
||||
- `cover_image` — downloaded cover file
|
||||
- `title` / `author` — updated when match score ≥ 0.8
|
||||
|
||||
### `metadata_json` shape
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "openlibrary",
|
||||
"matched_at": "2026-06-03T12:00:00+00:00",
|
||||
"match_language": "es",
|
||||
"match_score": 0.92,
|
||||
"match_status": "matched",
|
||||
"user_input": { "title": "...", "author": "..." },
|
||||
"openlibrary": {
|
||||
"work_key": "/works/OL...",
|
||||
"edition_key": "...",
|
||||
"title": "...",
|
||||
"authors": ["..."],
|
||||
"cover_id": 12345,
|
||||
"cover_url": "https://covers.openlibrary.org/b/id/12345-L.jpg",
|
||||
"first_publish_year": 1605,
|
||||
"subjects": ["..."],
|
||||
"languages": ["spa"],
|
||||
"publishers": ["..."],
|
||||
"number_of_pages_median": 320
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API changes
|
||||
|
||||
### Upload response (unchanged path)
|
||||
|
||||
`POST /api/books/ebooks/` — response may include populated `cover_image` and updated `title`/`author` after sync enrichment.
|
||||
|
||||
### Detail
|
||||
|
||||
`GET /api/books/ebooks/{id}/` — adds read-only `metadata` (alias of `metadata_json`).
|
||||
|
||||
### Manual refresh
|
||||
|
||||
`POST /api/books/ebooks/{id}/enrich-metadata/` — returns updated `EBookDetailSerializer` payload.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `OPENLIBRARY_ENABLED` | `true` | Kill switch |
|
||||
| `OPENLIBRARY_PREFERRED_LANG` | `es` | Primary `lang` param |
|
||||
| `OPENLIBRARY_FALLBACK_LANG` | `en` | Fallback `lang` param |
|
||||
| `OPENLIBRARY_TIMEOUT_SECONDS` | `5` | HTTP timeout |
|
||||
| `OPENLIBRARY_USER_AGENT` | `CloudReader/1.0` | User-Agent header |
|
||||
|
||||
## Code layout
|
||||
|
||||
```
|
||||
backend/apps/books/services/
|
||||
├── openlibrary.py # Search, scoring, cover download
|
||||
└── metadata.py # enrich_ebook_metadata orchestrator
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Upload with title `Don Quijote`, author `Cervantes` → cover + Spanish-friendly metadata.
|
||||
2. Upload with nonsense title/author → 201, no cover, `match_status: not_found`.
|
||||
3. `POST .../enrich-metadata/` on existing ebook refreshes metadata.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Backend API Specification — Cloud Reader
|
||||
|
||||
## Overview
|
||||
|
||||
The Cloud Reader backend provides a RESTful API for managing a user's personal book library. Built with Django 5 + Django REST Framework.
|
||||
|
||||
## Models
|
||||
|
||||
### Book
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|------------------|--------------------|---------------------------------|
|
||||
| `title` | `CharField(500)` | Required |
|
||||
| `author` | `CharField(500)` | Required |
|
||||
| `genre` | `CharField(200)` | Optional, blank allowed |
|
||||
| `description` | `TextField` | Optional, blank allowed |
|
||||
| `cover_image_url`| `URLField` | Optional, blank allowed |
|
||||
| `isbn` | `CharField(20)` | Optional, blank allowed |
|
||||
| `total_pages` | `PositiveIntegerField` | Default 0 |
|
||||
| `current_page` | `PositiveIntegerField` | Default 0, validated ≤ total |
|
||||
| `reading_status` | `CharField(20)` | Choices: `not_started`, `reading`, `finished`, `dnf` |
|
||||
| `owner` | `ForeignKey(User)` | Set automatically on create |
|
||||
| `created_at` | `DateTimeField` | Auto-set on create |
|
||||
| `updated_at` | `DateTimeField` | Auto-set on update |
|
||||
|
||||
**Properties:**
|
||||
- `reading_progress` — computed `(current_page / total_pages) * 100`, returns `0.0` when `total_pages` is 0.
|
||||
|
||||
**Indexes:** Composite indexes on `(owner, reading_status)`, `(owner, title)`, `(owner, author)`.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Base URL: `/api/`
|
||||
|
||||
Authentication: SessionAuthentication + BasicAuthentication (DRF defaults).
|
||||
|
||||
Permissions: All book endpoints require `IsAuthenticated`. Users can only access their own books.
|
||||
|
||||
### Books
|
||||
|
||||
| Method | URL | Action | Serializer |
|
||||
|----------|------------------------------------|---------------|--------------------|
|
||||
| `GET` | `/api/books/` | List books | `BookListSerializer` |
|
||||
| `POST` | `/api/books/` | Create book | `BookDetailSerializer` |
|
||||
| `GET` | `/api/books/{id}/` | Retrieve book | `BookDetailSerializer` |
|
||||
| `PUT` | `/api/books/{id}/` | Full update | `BookDetailSerializer` |
|
||||
| `PATCH` | `/api/books/{id}/` | Partial update| `BookDetailSerializer` |
|
||||
| `DELETE` | `/api/books/{id}/` | Delete book | — |
|
||||
| `POST` | `/api/books/{id}/mark_finished/` | Mark finished | `BookDetailSerializer` |
|
||||
| `GET` | `/api/books/stats/` | Library stats | — (custom) |
|
||||
|
||||
### Query Parameters (List)
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|------------------|----------|--------------------------------------------------|
|
||||
| `page` | int | Page number for pagination (20 items/page) |
|
||||
| `sort_by` | string | `title`, `-title`, `author`, `-author`, `-created_at`, `-updated_at`, `-reading_progress`, `reading_progress` |
|
||||
| `reading_status` | string | Filter by status value |
|
||||
| `search` | string | Search in title and author fields (icontains) |
|
||||
|
||||
### Stats Response
|
||||
|
||||
```json
|
||||
{
|
||||
"total_books": 10,
|
||||
"finished": 3,
|
||||
"reading": 4,
|
||||
"not_started": 3
|
||||
}
|
||||
```
|
||||
|
||||
### Mark Finished
|
||||
|
||||
`POST /api/books/{id}/mark_finished/` sets `reading_status` to `finished` and `current_page` to `total_pages`.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- Title and author cannot be empty or whitespace-only
|
||||
- `current_page` cannot exceed `total_pages` (when `total_pages > 0`)
|
||||
- Setting `reading_status` to `finished` automatically sets `current_page = total_pages`
|
||||
|
||||
## Admin
|
||||
|
||||
Books are registered in Django admin with list display, filters by `reading_status` and `genre`, and search by `title`/`author`.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Book Search & Discovery — Spec
|
||||
|
||||
## Overview
|
||||
Enable users to search books within the library and discover new books via filters and a dedicated detail view.
|
||||
|
||||
## Backend API Contracts
|
||||
|
||||
### Book List & Search
|
||||
`GET /api/books/`
|
||||
|
||||
**Query Parameters:**
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `q` | string | Full-text search across title, author, genre |
|
||||
| `genre` | string | Exact filter by genre |
|
||||
| `author` | string | Exact filter by author |
|
||||
| `reading_status` | string | Filter: `want_to_read`, `reading`, `finished`, `dnf` |
|
||||
| `ordering` | string | `title`, `author`, `genre`, `created_at` (prefix `-` for desc) |
|
||||
| `page` | int | Page number (default: 1) |
|
||||
|
||||
**Response (paginated):**
|
||||
```json
|
||||
{
|
||||
"count": 42,
|
||||
"next": "http://.../?page=2",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Dune",
|
||||
"author": "Frank Herbert",
|
||||
"genre": "Science Fiction",
|
||||
"reading_status": "finished",
|
||||
"reading_status_display": "Finished",
|
||||
"cover_image": "https://..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Book Detail
|
||||
`GET /api/books/{id}/`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Dune",
|
||||
"author": "Frank Herbert",
|
||||
"genre": "Science Fiction",
|
||||
"description": "...",
|
||||
"reading_status": "finished",
|
||||
"reading_status_display": "Finished",
|
||||
"cover_image": "https://...",
|
||||
"total_pages": 688,
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-15T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Genre / Author Discovery
|
||||
`GET /api/books/genres/` → `["Fiction", "Science Fiction", ...]`
|
||||
|
||||
`GET /api/books/authors/` → `["Frank Herbert", "Ursula K. Le Guin", ...]`
|
||||
|
||||
## Frontend Components
|
||||
|
||||
### LibraryPage (enhanced)
|
||||
- **Search bar** at top: text input with debounced `onChange` → calls API with `q` param
|
||||
- **Filter row**: genre dropdown, author dropdown, reading status dropdown
|
||||
- Genre/Author dropdowns populated from `/api/books/genres/` and `/api/books/authors/`
|
||||
- Reading status uses static enum values (`READING_STATUS_OPTIONS`)
|
||||
- **Results grid**: card layout showing cover, title, author, reading status badge
|
||||
- **Empty state**: "No books found" with clear message when results are empty
|
||||
- **Loading state**: spinner/skeleton while fetching
|
||||
- **Click card → navigate to** `/books/{id}`
|
||||
|
||||
### BookDetailPage (new)
|
||||
- Shows full book info: cover, title, author, genre, description, reading status, total pages
|
||||
- Back button to return to library
|
||||
- Clean, mobile-responsive layout
|
||||
|
||||
### API Client — `frontend/src/api/books.ts`
|
||||
|
||||
| Method | Endpoint | Returns |
|
||||
|--------|----------|---------|
|
||||
| `searchBooks(params)` | `GET /api/books/` | `{count, results: BookListItem[]}` |
|
||||
| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` |
|
||||
| `getGenres()` | `GET /api/books/genres/` | `string[]` |
|
||||
| `getAuthors()` | `GET /api/books/authors/` | `string[]` |
|
||||
|
||||
## Routes (Frontend)
|
||||
| Path | Component | Auth |
|
||||
|------|-----------|------|
|
||||
| `/` | LibraryPage | Protected |
|
||||
| `/books/:id` | BookDetailPage | Protected |
|
||||
|
||||
## Data Flow
|
||||
1. User types in search bar → 300ms debounce → `GET /api/books/?q=...`
|
||||
2. User selects filter → `GET /api/books/?genre=...&author=...&reading_status=...`
|
||||
3. User clicks result → navigate to `/books/:id`
|
||||
4. BookDetailPage → `GET /api/books/{id}/`
|
||||
|
||||
## Mobile Optimizations
|
||||
- Filters collapse into a toggleable panel on small screens
|
||||
- Cards stack in single column on mobile
|
||||
- Touch-friendly tap targets (min 44px)
|
||||
@@ -0,0 +1,246 @@
|
||||
# US: Customizable Mobile Reading Experience
|
||||
|
||||
**Issue:** https://gitea-dev.codescripters.org/HermesFactory/cloud-reader/issues (TBD)
|
||||
|
||||
## Overview
|
||||
|
||||
Add a full-screen reading view for ebooks with customizable typography, themes,
|
||||
table of contents navigation, and orientation support. Mobile-first, responsive
|
||||
design that adapts to any screen size.
|
||||
|
||||
---
|
||||
|
||||
## Backend Specification
|
||||
|
||||
### New Models
|
||||
|
||||
#### `apps.books.models.Chapter`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------------|--------------------|--------------------------------|
|
||||
| id | AutoField (PK) | |
|
||||
| book | FK -> Book | related_name="chapters" |
|
||||
| title | CharField(512) | Chapter title |
|
||||
| number | PositiveIntegerField | Chapter ordering / TOC index |
|
||||
| content | TextField | Chapter text/markdown content |
|
||||
| created_at | DateTimeField | auto_now_add |
|
||||
| updated_at | DateTimeField | auto_now |
|
||||
|
||||
**Constraints:** UniqueConstraint(book, chapter_number)
|
||||
**Ordering:** [book, number]
|
||||
**Index:** FK to book with db_index
|
||||
|
||||
#### `apps.books.models.ReadingProgress`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------------------|--------------------|--------------------------------|
|
||||
| id | AutoField (PK) | |
|
||||
| user | FK -> User | related_name="reading_progress"|
|
||||
| book | FK -> Book | related_name="reading_progress"|
|
||||
| current_chapter | PositiveIntegerField | Last chapter number |
|
||||
| current_position | PositiveIntegerField | Position within chapter (paragraph) |
|
||||
| percentage | FloatField | 0.0 - 100.0 overall progress |
|
||||
| updated_at | DateTimeField | auto_now |
|
||||
|
||||
**Constraints:** UniqueConstraint(user, book)
|
||||
**Indexes:** (user, book) composite, (user) filter for list queries
|
||||
|
||||
#### `apps.reader.models.ReadingSettings`
|
||||
|
||||
New app `apps/reader/` for reading preferences, isolated from book data model.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------------------|--------------------|-------------------------------|
|
||||
| id | AutoField (PK) | |
|
||||
| user | OneToOneField -> User | related_name="reading_settings" |
|
||||
| font_family | CharField(32) | "sans-serif", "serif", "monospace" |
|
||||
| font_size | PositiveSmallIntegerField | 12-32, default 18 |
|
||||
| line_height | FloatField | 1.2 - 2.0, default 1.6 |
|
||||
| margin_width | PositiveSmallIntegerField | 8-48, default 16 (px) |
|
||||
| background_color | CharField(7) | Hex color, default "#f5f0eb" |
|
||||
| text_color | CharField(7) | Hex color, default "#1a1a1a" |
|
||||
| brightness | PositiveSmallIntegerField | 0-100, default 100 |
|
||||
| orientation_lock | CharField(16) | "auto", "portrait", "landscape" |
|
||||
| theme | CharField(32) | "sepia", "dark", "light", "paper" |
|
||||
| created_at | DateTimeField | auto_now_add |
|
||||
| updated_at | DateTimeField | auto_now |
|
||||
|
||||
### New API Endpoints
|
||||
|
||||
All under `/api/` prefix, authenticated with JWT.
|
||||
|
||||
#### Reader Settings (`/api/reader/settings/`)
|
||||
|
||||
| Method | URL | Action |
|
||||
|--------|------------------------------|---------------------------|
|
||||
| GET | /api/reader/settings/ | Get current user settings |
|
||||
| PUT | /api/reader/settings/ | Create/update settings |
|
||||
| PATCH | /api/reader/settings/ | Partial update settings |
|
||||
|
||||
- Single-object endpoint (one settings record per user, auto-created on first GET)
|
||||
- Validation: font_size 12-32, line_height 1.2-2.0, margin_width 8-48
|
||||
|
||||
#### Reading Progress (`/api/books/{id}/progress/`)
|
||||
|
||||
| Method | URL | Action |
|
||||
|--------|----------------------------------------|------------------------------|
|
||||
| GET | /api/books/{id}/progress/ | Get reading progress for book|
|
||||
| PUT | /api/books/{id}/progress/ | Create/update reading progress|
|
||||
|
||||
- Nested under book detail
|
||||
- Auto-creates progress record on first PUT
|
||||
|
||||
#### Chapters (`/api/books/{id}/chapters/`)
|
||||
|
||||
| Method | URL | Action |
|
||||
|--------|----------------------------------------|------------------------------|
|
||||
| GET | /api/books/{id}/chapters/ | List chapters for book (TOC) |
|
||||
| GET | /api/books/{id}/chapters/{number}/ | Get specific chapter content |
|
||||
|
||||
- Ordering by `number`
|
||||
- Used by frontend TOC sidebar and content loading
|
||||
|
||||
---
|
||||
|
||||
## Frontend Specification
|
||||
|
||||
### New Pages
|
||||
|
||||
#### `/reader/:bookId` — ReadingPage
|
||||
|
||||
Full-screen reading view with:
|
||||
- Chapter content display (left/right swiping or scroll)
|
||||
- Bottom toolbar: TOC toggle, Settings toggle, Progress indicator
|
||||
- Top bar: Back button, Book title, Chapter title
|
||||
- Swipe/tap/page navigation between chapters
|
||||
|
||||
### New Components
|
||||
|
||||
#### `ReaderToolbar`
|
||||
- Fixed bottom toolbar
|
||||
- TOC button (opens TOC drawer)
|
||||
- Settings/theme button (opens settings panel)
|
||||
- Progress bar showing overall reading progress
|
||||
|
||||
#### `TableOfContents`
|
||||
- Slide-in drawer from left
|
||||
- Lists all chapters with current chapter highlighted
|
||||
- Tap on chapter to navigate
|
||||
- Shows reading progress per chapter
|
||||
|
||||
#### `ReadingSettingsPanel`
|
||||
- Slide-in drawer from right (or bottom sheet on mobile)
|
||||
- Controls:
|
||||
- Theme presets: Sepia, Dark, Light, Paper
|
||||
- Font family: Sans-serif, Serif, Monospace
|
||||
- Font size slider (12-32)
|
||||
- Line height slider (1.2-2.0)
|
||||
- Margin/padding control
|
||||
- Orientation lock toggle (Auto / Portrait / Landscape)
|
||||
- All changes persist immediately via API
|
||||
- LocalStorage fallback when offline
|
||||
|
||||
### New Hooks
|
||||
|
||||
#### `useReadingSettings(bookId)`
|
||||
- Fetches user reading settings from API
|
||||
- Returns current settings + update function
|
||||
- Applies CSS custom properties to document root
|
||||
- Falls back to defaults if API unavailable
|
||||
|
||||
#### `useChapters(bookId)`
|
||||
- Fetches chapter list for TOC
|
||||
- Returns chapters array, current chapter, navigate function
|
||||
- Prefetches next/prev chapter content
|
||||
|
||||
#### `useReadingProgress(bookId)`
|
||||
- Fetches/updates reading progress
|
||||
- Auto-saves position on chapter change and periodic interval
|
||||
|
||||
### New Types
|
||||
|
||||
```typescript
|
||||
interface Chapter {
|
||||
id: number;
|
||||
book: number;
|
||||
title: string;
|
||||
number: number;
|
||||
content?: string; // Only present when fetching individual chapter
|
||||
}
|
||||
|
||||
interface ChapterSummary {
|
||||
id: number;
|
||||
book: number;
|
||||
title: string;
|
||||
number: number;
|
||||
}
|
||||
|
||||
interface ReadingSettings {
|
||||
font_family: "sans-serif" | "serif" | "monospace";
|
||||
font_size: number;
|
||||
line_height: number;
|
||||
margin_width: number;
|
||||
background_color: string;
|
||||
text_color: string;
|
||||
brightness: number;
|
||||
orientation_lock: "auto" | "portrait" | "landscape";
|
||||
theme: "sepia" | "dark" | "light" | "paper";
|
||||
}
|
||||
|
||||
interface ReadingProgress {
|
||||
current_chapter: number;
|
||||
current_position: number;
|
||||
percentage: number;
|
||||
updated_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
### CSS / Theming
|
||||
|
||||
Reading view uses CSS custom properties driven by reading settings:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--reader-bg: var(--bg-color, #f5f0eb);
|
||||
--reader-text: var(--text-color, #1a1a1a);
|
||||
--reader-font-family: var(--font-family, "Georgia", serif);
|
||||
--reader-font-size: var(--font-size, 18px);
|
||||
--reader-line-height: var(--line-height, 1.6);
|
||||
--reader-margin: var(--margin-width, 16px);
|
||||
}
|
||||
```
|
||||
|
||||
Three theme presets:
|
||||
- **Sepia**: `bg:#f5f0eb`, `text:#1a1a1a` — warm, easy on eyes
|
||||
- **Dark**: `bg:#1a1a2e`, `text:#e0e0e0` — for low-light reading
|
||||
- **Light**: `bg:#ffffff`, `text:#1a1a1a` — crisp and clean
|
||||
- **Paper**: `bg:#e8e0d4`, `text:#2c2c2c` — book-like feel
|
||||
|
||||
### Orientation Support
|
||||
|
||||
- CSS `@media (orientation: portrait)` and `@media (orientation: landscape)` breakpoints
|
||||
- Reading settings panel includes orientation lock toggle
|
||||
- On mobile, landscape mode expands content horizontally with wider margins
|
||||
- Portrait mode stacks controls vertically for thumb-reachable UI
|
||||
|
||||
### Routing
|
||||
|
||||
Add to App.tsx:
|
||||
```
|
||||
/ → LibraryPage
|
||||
/books/:bookId → BookDetailPage
|
||||
/reader/:bookId → ReadingPage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Backend models + migrations (Chapter, ReadingProgress, ReadingSettings)
|
||||
2. Backend serializers + views + URLs
|
||||
3. Frontend types + API client
|
||||
4. Frontend hooks (useReadingSettings, useChapters, useReadingProgress)
|
||||
5. Frontend components (ReadingSettingsPanel, TableOfContents, ReaderToolbar)
|
||||
6. Frontend page (ReadingPage)
|
||||
7. Routing updates
|
||||
8. CSS / theming
|
||||
@@ -0,0 +1,73 @@
|
||||
# 011 — Web reader (react-reader)
|
||||
|
||||
**Status:** Implemented
|
||||
**Created:** 2026-06-03
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the custom HTML chapter reader with [react-reader](https://github.com/gerhardsletten/react-reader) (epub.js) for paginated EPUB reading in the web app. Keep the existing toolbar and settings panel chrome. Block PDFs from the in-browser reader.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Library (EPUB only) → /read/:id → ReadingPage
|
||||
→ GET /api/books/ebooks/{id}/ (metadata, format guard)
|
||||
→ GET /api/books/ebooks/{id}/file/ (authenticated EPUB blob)
|
||||
→ ReactReader (blob URL + CFI location)
|
||||
→ PATCH /api/books/ebooks/{id}/progress/ (epub_location + percentage)
|
||||
```
|
||||
|
||||
EPUB files are fetched with JWT via the API, converted to a blob URL client-side, and passed to react-reader. This avoids unauthenticated `/media/` URLs and CORS issues in dev.
|
||||
|
||||
## Backend changes
|
||||
|
||||
### `GET /api/books/ebooks/{id}/file/`
|
||||
|
||||
- Authenticated, owner-only
|
||||
- Returns `FileResponse` with `Content-Type: application/epub+zip`
|
||||
- Returns `400` if format is not `epub`
|
||||
|
||||
### `ReadingProgress.epub_location`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `epub_location` | CharField(2048) | EPUB CFI string for resume position |
|
||||
|
||||
Exposed on `GET/PATCH /api/books/ebooks/{id}/progress/` and in `EBookDetail.progress`.
|
||||
|
||||
`current_position` stores whole-book percentage (0–100) derived from epub.js locations.
|
||||
|
||||
## Frontend changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `pages/ReadingPage.tsx` | `ReactReader` replaces chapter HTML rendering |
|
||||
| `hooks/useEpubReader.ts` | Blob load, CFI state, debounced progress save |
|
||||
| `utils/epubRendition.ts` | Theme/font application via `getRendition` |
|
||||
| `api/books.ts` | `getEpubFile(id)` |
|
||||
| `pages/Library.tsx` | Block PDF open with notice |
|
||||
| `App.tsx` | Single route `/read/:id`; `/reader/:id` redirects |
|
||||
|
||||
### Removed (web-only)
|
||||
|
||||
- `useChapters.ts`, `TableOfContents.tsx`, `Reader.tsx`, `useReadingProgress.ts`
|
||||
|
||||
Backend `/toc/` and `/content/` endpoints remain for mobile/API consumers.
|
||||
|
||||
## EPUB-only policy
|
||||
|
||||
- Library: clicking a PDF shows a dismissible notice; no navigation to reader
|
||||
- ReadingPage: deep-link guard if `format !== 'epub'`
|
||||
- Upload still accepts PDF for storage; web reader is EPUB-only
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Frontend: `react-reader` (^2.0.15)
|
||||
|
||||
## Verification
|
||||
|
||||
1. Open an EPUB from library → paginated reading, swipe/tap page turns, built-in TOC
|
||||
2. Close and reopen → resumes at saved CFI
|
||||
3. Change theme/font in settings → applies inside epub iframe
|
||||
4. Click a PDF in library → notice shown, reader not opened
|
||||
5. `GET /api/books/ebooks/{id}/file/` without auth → 401
|
||||
@@ -0,0 +1,56 @@
|
||||
# 012 — Library book context menu
|
||||
|
||||
**Status:** Implemented
|
||||
**Created:** 2026-06-03
|
||||
|
||||
## Objective
|
||||
|
||||
Add a custom right-click context menu on each book card in the library with **Sync metadata** (Open Library refresh) and **Remove** (delete from library). Show toast notifications for success, warning, and error outcomes.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Library book card (contextmenu)
|
||||
→ BookContextMenu
|
||||
→ Sync metadata: POST /api/books/ebooks/{id}/enrich-metadata/
|
||||
→ Remove: confirm dialog → DELETE /api/books/ebooks/{id}/
|
||||
→ ToastProvider (app-wide) → success | warning | error toasts
|
||||
```
|
||||
|
||||
Backend endpoints already exist; no API changes required.
|
||||
|
||||
## Frontend changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `api/books.ts` | `enrichEBookMetadata(id)` → `POST .../enrich-metadata/` |
|
||||
| `types/book.ts` | `OpenLibraryMetadata` with `match_status` |
|
||||
| `hooks/useToast.tsx` | `ToastProvider`, `showToast({ message, variant })`, auto-dismiss ~4s |
|
||||
| `components/ToastContainer.tsx` | Fixed bottom-right toast stack |
|
||||
| `components/BookContextMenu.tsx` | Positioned menu; sync + remove actions |
|
||||
| `pages/Library.tsx` | `onContextMenu` on cards; local state updates |
|
||||
| `App.tsx` | Wrap routes with `ToastProvider` |
|
||||
|
||||
## Context menu behavior
|
||||
|
||||
- Opens on right-click (`contextmenu`); browser default menu suppressed
|
||||
- Position clamped to viewport; closes on outside click, Escape, or scroll
|
||||
- **Sync metadata:** updates card title/author/cover from response; toast by `metadata.match_status`:
|
||||
- `matched` → success
|
||||
- `not_found` → warning
|
||||
- other → neutral success
|
||||
- **Remove:** `window.confirm` before delete; removes card from list on success
|
||||
- Menu clicks do not trigger card navigation to reader
|
||||
|
||||
## Dependencies
|
||||
|
||||
None (no new npm packages).
|
||||
|
||||
## Verification
|
||||
|
||||
1. Right-click a book → menu appears at cursor
|
||||
2. Sync metadata on a matched book → cover/title may update, success toast
|
||||
3. Sync on unmatched book → warning toast, no crash
|
||||
4. Remove → confirm → book disappears, success toast
|
||||
5. Cancel remove → book stays, menu closes
|
||||
6. Left-click still opens reader (EPUB) or PDF notice
|
||||
@@ -0,0 +1,54 @@
|
||||
# 013 — Frontend i18n (react-i18n-lite)
|
||||
|
||||
**Status:** Implemented
|
||||
**Created:** 2026-06-04
|
||||
|
||||
## Objective
|
||||
|
||||
Internationalize the web UI in English (`en-US`) and Spanish (`es-ES`) using [react-i18n-lite](https://www.npmjs.com/package/react-i18n-lite).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
App.tsx
|
||||
└── I18nProvider (TranslationContainer)
|
||||
├── resolveDefaultLanguage() — localStorage → navigator → en-US
|
||||
├── LanguagePersistence — sync setLanguage ↔ localStorage, html[lang]
|
||||
└── AuthProvider → ToastProvider → routes
|
||||
```
|
||||
|
||||
Components call `useTranslation()` and `t('namespace.key', { interpolation })`.
|
||||
|
||||
## Locale files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `frontend/src/locales/en-US.ts` | English dictionary |
|
||||
| `frontend/src/locales/es-ES.ts` | Spanish dictionary |
|
||||
| `frontend/src/locales/index.ts` | `locales` map, `SupportedLanguage`, helpers |
|
||||
|
||||
Key namespaces: `common`, `auth`, `library`, `contextMenu`, `toast`, `settings`, `reader`, `addBook`, `bookDetail`, `search`, `annotations`.
|
||||
|
||||
## Language selection
|
||||
|
||||
- **Settings page** — UI Language dropdown (English / Español)
|
||||
- **Persistence** — `localStorage` key `cloud-reader.locale`
|
||||
- **Default** — saved preference, else `navigator.language` (`es*` → `es-ES`), else `en-US`
|
||||
|
||||
## Scope
|
||||
|
||||
**Translated:** All user-facing chrome (library, auth, reader toolbar/settings/TOC, add book, book detail, annotations, context menu, toasts).
|
||||
|
||||
**Not translated:** EPUB body content, API error bodies, backend `reading_status_display`, Open Library metadata fields.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `react-i18n-lite` (^1.0.10)
|
||||
|
||||
## Verification
|
||||
|
||||
1. Open app — labels match browser or saved language
|
||||
2. Settings → switch to Español — library/auth update without reload
|
||||
3. Refresh — language persists
|
||||
4. Context menu and toasts show translated strings
|
||||
5. `yarn build` succeeds
|
||||
@@ -0,0 +1,46 @@
|
||||
# 014 — Library reading progress badges
|
||||
|
||||
**Status:** Implemented
|
||||
**Created:** 2026-06-04
|
||||
|
||||
## Objective
|
||||
|
||||
Show accurate reading state on library book cards using saved EPUB progress instead of always displaying "Want to Read" (`Quiero leer`).
|
||||
|
||||
## Data source
|
||||
|
||||
- `GET /api/books/ebooks/` returns `progress` (0–100) from `ReadingProgress.current_position` via `EBookListSerializer.get_progress`.
|
||||
- Progress is updated by the web reader (`useEpubReader` → `PATCH .../progress/`).
|
||||
|
||||
## Display rules
|
||||
|
||||
| Progress | `opened` | Badge | Cover extra |
|
||||
|----------|----------|-------|-------------|
|
||||
| `null` or `0` | false | Want to Read | — |
|
||||
| `0` | true | Opened | — |
|
||||
| `1–98` | — | Reading · N% | Green progress bar on cover |
|
||||
| `≥ 99` | — | Finished | — |
|
||||
|
||||
**Opened** is set when the reader first displays (CFI saved, 0% progress). Turning pages moves to **Reading**.
|
||||
|
||||
Threshold constant: `FINISHED_PROGRESS_THRESHOLD = 99` in `frontend/src/utils/libraryStatus.ts`.
|
||||
|
||||
## Frontend changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `utils/libraryStatus.ts` | `deriveReadingStatus`, `normalizeProgressPercent` |
|
||||
| `pages/Library.tsx` | Map API progress → status; badge labels; client-side status filter |
|
||||
| `locales/en-US.ts`, `es-ES.ts` | `library.readingStatus.readingWithProgress` |
|
||||
| `pages/Library.module.css` | Cover progress bar styles |
|
||||
|
||||
## Status filter
|
||||
|
||||
The reading-status filter in the library panel now filters client-side by derived status (`want_to_read`, `reading`, `finished`).
|
||||
|
||||
## Verification
|
||||
|
||||
1. New upload → **Want to Read** / **Quiero leer**
|
||||
2. Partial read → **Reading · N%** + progress bar
|
||||
3. Near end (≥ 99%) → **Finished** / **Terminado**
|
||||
4. Filters by Leyendo / Terminado work as expected
|
||||
@@ -0,0 +1,83 @@
|
||||
# 015 — EPUB bookmarks and notes (physical-book UX)
|
||||
|
||||
**Status:** Implemented
|
||||
**Created:** 2026-06-04
|
||||
|
||||
## Objective
|
||||
|
||||
Let users select passage text while reading an EPUB, save a bookmark (with optional thought), list markers in chapter order, and jump back to the exact location. Global view groups markers by book in a Reddit-style thread layout.
|
||||
|
||||
## Fix: AnnotationsProvider
|
||||
|
||||
`AnnotationsProvider` wraps all routes in [`App.tsx`](frontend/src/App.tsx) so `/bookmarks-notes` and the reader can use `useAnnotations()`.
|
||||
|
||||
## Data model
|
||||
|
||||
`Bookmark` (annotations app) references **`books.EBook`**, not catalog `Book`:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `ebook` | FK to uploaded ebook |
|
||||
| `epub_cfi` | EPUB CFI anchor |
|
||||
| `chapter_index` | Spine index for sort order |
|
||||
| `chapter_title` | Display label |
|
||||
| `location_text` | Selected passage quote |
|
||||
| `content` | Optional user thought (empty = bookmark only) |
|
||||
| `page` | Legacy display field (`chapter_index + 1`) |
|
||||
|
||||
Unique: `(user, ebook, epub_cfi)`.
|
||||
|
||||
Default API ordering: `chapter_index`, `epub_cfi`.
|
||||
|
||||
Legacy `Note` model remains for old API; new UX uses `Bookmark.content` only.
|
||||
|
||||
## Reader flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant EpubView
|
||||
participant Popover
|
||||
participant API
|
||||
User->>EpubView: Select text
|
||||
EpubView->>Popover: Show near selection
|
||||
User->>Popover: Save optional thought
|
||||
Popover->>API: POST /annotations/bookmarks/
|
||||
```
|
||||
|
||||
1. Text selection via epub.js `selected` event and content `mouseup` hook.
|
||||
2. [`SelectionPopover`](frontend/src/components/reader/SelectionPopover.tsx) — floating UI, optional textarea.
|
||||
3. Toolbar bookmark icon opens [`BookMarkersPanel`](frontend/src/components/reader/BookMarkersPanel.tsx) (current ebook only).
|
||||
4. “Go to passage” sets reader `location` to stored CFI (`/read/:id` with router state).
|
||||
|
||||
## Global page (`/bookmarks-notes`)
|
||||
|
||||
[`MarkerThreadsView`](frontend/src/components/annotations/MarkerThreadsView.tsx):
|
||||
|
||||
- Groups markers by ebook (collapsible book rows).
|
||||
- Within each book: chapter order, passage as blockquote, thought indented below (Reddit-style).
|
||||
- Optional filter: `/bookmarks-notes/:ebookId`.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/annotations/bookmarks/?ebook={id}`
|
||||
- `POST /api/annotations/bookmarks/` — body: `ebook`, `epub_cfi`, `chapter_index`, `chapter_title`, `location_text`, `content`
|
||||
|
||||
## i18n
|
||||
|
||||
New keys under `annotations.*` (EN/ES): `saveMarker`, `thoughtPlaceholder`, `bookmarkOnly`, `selectTextHint`, `goToPassage`, `inBookPanel`, `markerCount`, etc.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `/bookmarks-notes` loads without provider error.
|
||||
2. Select text in reader → popover → save with/without thought.
|
||||
3. Markers appear in reader panel and global page under correct book, in chapter order.
|
||||
4. “Go to passage” opens the correct location.
|
||||
|
||||
See also [016 — Bookmark reading anchor](016-bookmark-reading-anchor.md) for preserving reading position while peeking at bookmarks.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- PDF selection
|
||||
- Multiple replies per passage
|
||||
- Migrating legacy `Note` rows into `Bookmark`
|
||||
@@ -0,0 +1,87 @@
|
||||
# 016 — Bookmark reading anchor
|
||||
|
||||
**Status:** Implemented
|
||||
**Created:** 2026-06-04
|
||||
**See also:** [015 — EPUB bookmarks and notes](015-epub-bookmarks-notes.md)
|
||||
|
||||
## Objective
|
||||
|
||||
Let users jump to a bookmark to review a passage without overwriting their true reading position. While peeking, show a high-visibility control to return to where they were reading.
|
||||
|
||||
## Definitions
|
||||
|
||||
| Term | Meaning |
|
||||
|------|---------|
|
||||
| **Reading anchor** | EPUB CFI (+ optional %) captured immediately before a bookmark peek |
|
||||
| **Peek mode** | Temporary view at a bookmark location; server `ReadingProgress` is not updated |
|
||||
| **Resume** | Jump back to the reading anchor and re-enable progress persistence |
|
||||
|
||||
## Problem (before)
|
||||
|
||||
“Go to passage” called `jumpToCfi`, which triggered `locationChanged` and debounced `PATCH /books/ebooks/{id}/progress/`, replacing `epub_location` and `current_position` with the bookmark. Library progress and the next reading session started at the bookmark instead of the real position.
|
||||
|
||||
## Triggers (enter peek mode)
|
||||
|
||||
- In-reader: **Go to passage** in [`BookMarkersPanel`](../frontend/src/components/reader/BookMarkersPanel.tsx)
|
||||
- Global: **Go to passage** on [`/bookmarks-notes`](../frontend/src/components/annotations/BookmarksNotesPage.tsx) → `/read/:id` with `state.epubLocation` (bookmark CFI)
|
||||
|
||||
## Non-triggers
|
||||
|
||||
- Table of contents navigation
|
||||
- Prev / next page buttons
|
||||
- Creating a new bookmark from text selection
|
||||
- Opening the book normally from the library (no `epubLocation` in router state)
|
||||
|
||||
## UX
|
||||
|
||||
### Resume control
|
||||
|
||||
- Component: [`ResumeReadingButton`](../frontend/src/components/reader/ResumeReadingButton.tsx)
|
||||
- Visible only when `isBookmarkPeekActive && readingAnchor != null`
|
||||
- Position: right edge, **above** the next-page chevron (`.reader-page-nav--next`)
|
||||
- Style: warm accent (`#ea580c` / `#f97316`), white label/icon; distinct from muted gray nav buttons
|
||||
- Action: `resumeReadingAnchor()` — hides control, returns to anchor CFI
|
||||
- i18n: `reader.resumeReading`, `reader.resumeReadingAria`
|
||||
|
||||
### Anchor policy
|
||||
|
||||
- First anchor is captured when peek starts; **additional “Go to passage” clicks while peeking do not replace the anchor** until the user resumes or leaves the reader.
|
||||
|
||||
## Progress rules
|
||||
|
||||
| Mode | `PATCH .../progress/` |
|
||||
|------|------------------------|
|
||||
| Normal reading | Yes (debounced on `locationChanged`, sync after locations ready) |
|
||||
| Bookmark peek | **No** — `flushProgress` / `scheduleProgress` no-op |
|
||||
| After resume | Yes — flush anchor CFI and percentage once |
|
||||
|
||||
### Load from bookmarks page
|
||||
|
||||
1. Fetch saved `ReadingProgress` from API.
|
||||
2. If `state.epubLocation` is set and saved `epub_location` exists → store saved location as **anchor**, set peek mode, open at bookmark CFI.
|
||||
3. Do not persist bookmark location as progress during peek.
|
||||
|
||||
### In-reader peek
|
||||
|
||||
1. Capture current `location` (CFI) as anchor (if valid).
|
||||
2. Enter peek mode, jump to bookmark CFI.
|
||||
|
||||
## API
|
||||
|
||||
No backend changes in v1. Anchor is session-only in [`useEpubReader`](../frontend/src/hooks/useEpubReader.ts).
|
||||
|
||||
## Verification
|
||||
|
||||
1. Read to ~30%, open markers, **Go to passage** on an early bookmark → jumps; after debounce, library/API progress still reflects ~30% (not bookmark).
|
||||
2. Orange **Back to reading** appears above the next chevron only during peek.
|
||||
3. Tap resume → returns to ~30%; button hides; progress saves resume.
|
||||
4. From `/bookmarks-notes`, **Go to passage** → peek + resume using saved progress as anchor.
|
||||
5. TOC / prev / next do not show the resume button.
|
||||
6. EN/ES strings present.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- PDF reader
|
||||
- Multiple anchor history stack
|
||||
- Backend `resume_epub_location` field
|
||||
- Peek mode for TOC jumps
|
||||
@@ -1,82 +0,0 @@
|
||||
# Frontend Component Specification — Cloud Reader
|
||||
|
||||
## Overview
|
||||
|
||||
React 19 + TypeScript SPA using Vite for development and production builds. State managed via React hooks and Context API. Routing via `react-router-dom` v7 with `lazy`/`Suspense` for code splitting.
|
||||
|
||||
## Components
|
||||
|
||||
### App (root)
|
||||
|
||||
- **Path:** `/`
|
||||
- **Layout:** Header with logo + `<Routes>` wrapper
|
||||
- **Routing:** `/` → `Library`, `*` → redirect to `/`
|
||||
- **Code splitting:** `Library` loaded via `React.lazy` + `<Suspense>`
|
||||
|
||||
### Library
|
||||
|
||||
- **State:** `books[]`, `stats`, `search`, `sortBy`, `statusFilter`, `currentPage`, `view`
|
||||
- **Views:** `library` (grid), `detail` (selected book), `add` (modal form)
|
||||
- **Sub-components:** `BookCard`, `BookDetail`, `BookForm`
|
||||
- **Data flow:** Calls `fetchBooks()` on mount and when filters/page change
|
||||
|
||||
### BookCard
|
||||
|
||||
- **Props:** `{ book: Book, onClick: (book: Book) => void }`
|
||||
- **Display:** Cover image (or first-letter placeholder), title, author, genre badge, reading progress bar with status color
|
||||
- **Interaction:** Click/keyboard-accessible (Enter/Space)
|
||||
|
||||
### BookDetail
|
||||
|
||||
- **Props:** `{ bookId: number, onBack: () => void, onUpdated: () => void }`
|
||||
- **Sections:** Cover, metadata (title, author, genre, ISBN), progress bar with page count, action buttons
|
||||
- **Actions:** Mark as Finished, Edit Details (switches to BookForm), Delete (with confirmation)
|
||||
- **States:** Loading, error, editing mode
|
||||
|
||||
### BookForm
|
||||
|
||||
- **Props:** `{ initialData?: Book, onSubmit: (data: BookFormData) => Promise<void>, onCancel: () => void }`
|
||||
- **Fields:** Title*, Author*, Genre, Description, Cover URL, ISBN, Total Pages, Current Page, Status
|
||||
- **Client validation:** Title/author required, page ≤ total pages
|
||||
- **Loading state:** Submit button shows "Saving..." when `isLoading`
|
||||
|
||||
## Types
|
||||
|
||||
```typescript
|
||||
interface Book {
|
||||
id: number; title: string; author: string; genre: string;
|
||||
description: string; cover_image_url: string; isbn: string;
|
||||
total_pages: number; current_page: number;
|
||||
reading_status: ReadingStatus; reading_progress: number;
|
||||
owner: string; created_at: string; updated_at: string;
|
||||
}
|
||||
|
||||
type ReadingStatus = 'not_started' | 'reading' | 'finished' | 'dnf';
|
||||
|
||||
interface BookFormData {
|
||||
title: string; author: string; genre: string;
|
||||
description: string; cover_image_url: string; isbn: string;
|
||||
total_pages: number; current_page: number; reading_status: ReadingStatus;
|
||||
}
|
||||
```
|
||||
|
||||
## API Client (`api/books.ts`)
|
||||
|
||||
| Function | HTTP Call |
|
||||
|----------------------|------------------------------------|
|
||||
| `fetchBooks(params)` | `GET /api/books/` |
|
||||
| `fetchBook(id)` | `GET /api/books/{id}/` |
|
||||
| `createBook(data)` | `POST /api/books/` |
|
||||
| `updateBook(id, data)` | `PATCH /api/books/{id}/` |
|
||||
| `deleteBook(id)` | `DELETE /api/books/{id}/` |
|
||||
| `markAsFinished(id)` | `POST /api/books/{id}/mark_finished/` |
|
||||
| `fetchBookStats()` | `GET /api/books/stats/` |
|
||||
|
||||
## Styling
|
||||
|
||||
Dark theme with CSS custom properties. All styles in `App.css`. Responsive grid layout with breakpoint at 768px.
|
||||
|
||||
## Build & Dev
|
||||
|
||||
- `yarn workspace frontend dev` — Vite dev server on port 5173 with API proxy to 127.0.0.1:8000
|
||||
- `yarn workspace frontend build` — TypeScript check + Vite production build
|
||||
@@ -0,0 +1,99 @@
|
||||
# Mobile Book Search & Discovery — Spec
|
||||
|
||||
## Overview
|
||||
Enhance the existing book search experience with mobile-first features: voice search via the Web Speech API, real-time autocomplete suggestions, and touch-optimized responsive layout.
|
||||
|
||||
## Prerequisites
|
||||
- Backend endpoints already exist (from `docs/backend/search-discovery-spec.md`):
|
||||
- `GET /api/books/?q=...&genre=...&author=...&reading_status=...` — paginated search
|
||||
- `GET /api/books/{id}/` — book detail
|
||||
- `GET /api/books/genres/` — genre discovery
|
||||
- `GET /api/books/authors/` — author discovery
|
||||
- Frontend `LibraryPage` and `BookDetailPage` components exist but lacked API client methods and types (fixed in this PR).
|
||||
|
||||
## Frontend API Client Additions
|
||||
|
||||
### `frontend/src/types/book.ts` — New exports
|
||||
|
||||
```typescript
|
||||
export interface BookSearchParams {
|
||||
q?: string;
|
||||
genre?: string;
|
||||
author?: string;
|
||||
reading_status?: string;
|
||||
ordering?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export const READING_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "All Statuses" },
|
||||
{ value: "want_to_read", label: "Want to Read" },
|
||||
{ value: "reading", label: "Reading" },
|
||||
{ value: "finished", label: "Finished" },
|
||||
{ value: "dnf", label: "Did Not Finish" },
|
||||
];
|
||||
```
|
||||
|
||||
### `frontend/src/api/books.ts` — New methods on `booksApi`
|
||||
|
||||
| Method | Endpoint | Returns |
|
||||
|--------|----------|---------|
|
||||
| `searchBooks(params)` | `GET /api/books/` | `{ count, results: BookListItem[] }` |
|
||||
| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` |
|
||||
| `getGenres()` | `GET /api/books/genres/` | `string[]` |
|
||||
| `getAuthors()` | `GET /api/books/authors/` | `string[]` |
|
||||
|
||||
## Mobile Features
|
||||
|
||||
### 1. Voice Search
|
||||
- **Hook**: `useVoiceSearch` in `frontend/src/hooks/useVoiceSearch.ts`
|
||||
- Uses the Web Speech API (`SpeechRecognition` / `webkitSpeechRecognition`)
|
||||
- Returns: `{ isListening, transcript, isSupported, startListening, stopListening, hasError }`
|
||||
- Renders a microphone icon button next to the search input
|
||||
- On mobile, tapping the mic icon triggers the native speech recognition prompt
|
||||
- On success, populates the search input with the transcript and triggers a search
|
||||
- Graceful degradation: if SpeechRecognition API is unavailable, the mic button is hidden
|
||||
|
||||
### 2. Real-Time Suggestions (Autocomplete)
|
||||
- Component: `SearchSuggestions` rendered as a dropdown below the search input
|
||||
- On each keystroke (debounced 200ms), fetches `GET /api/books/?q=...&page_size=5` for suggestions
|
||||
- Shows up to 5 book title/author suggestions in a styled dropdown list
|
||||
- Clicking a suggestion navigates directly to `/books/{id}`
|
||||
- Clicking outside or pressing Escape dismisses the dropdown
|
||||
- Combines with existing full search results — suggestions are fast previews, not the main result list
|
||||
|
||||
### 3. Mobile-Responsive Enhancements
|
||||
- Filters panel is **collapsed by default** on mobile, toggleable via a "Filters" button
|
||||
- Touch targets minimum 44px (WCAG 2.1)
|
||||
- Results grid switches to **single column** below 600px viewport width
|
||||
- Search input and filters panel stack vertically on small screens
|
||||
- Add CSS breakpoints via inline styles and a `useMediaQuery` hook
|
||||
- Bottom navigation-style action buttons on mobile (Add Book, Bookmarks, Settings become icon-only)
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```
|
||||
LibraryPage
|
||||
├── Header (title, count, action buttons)
|
||||
├── SearchInput
|
||||
│ ├── TextInput (debounced 300ms)
|
||||
│ ├── VoiceSearchButton (microphone icon)
|
||||
│ └── SearchSuggestions (dropdown, debounced 200ms)
|
||||
├── FiltersButton (mobile: toggle; desktop: always visible)
|
||||
├── FiltersPanel (collapsible on mobile)
|
||||
│ ├── GenreSelect
|
||||
│ ├── AuthorSelect
|
||||
│ ├── StatusSelect
|
||||
│ └── ClearFiltersButton
|
||||
├── LoadingState (skeleton grid)
|
||||
├── ErrorState (message + retry button)
|
||||
├── EmptyState (no results / no books)
|
||||
└── ResultsGrid (responsive: auto-fill vs single column)
|
||||
```
|
||||
|
||||
## Mobile-First CSS Strategy
|
||||
- Use inline styles with `@media` queries in a shared `breakpoints.ts` utility
|
||||
- Breakpoints: sm = 480px, md = 768px, lg = 1024px
|
||||
- Base styles are mobile-first (single column, full width)
|
||||
- Media queries expand to multi-column grid and horizontal layout on larger screens
|
||||
@@ -0,0 +1,96 @@
|
||||
# 009 — Expo Mobile Application Integration
|
||||
|
||||
**Issue:** #16
|
||||
**Status:** Draft
|
||||
**Created:** 2026-05-29
|
||||
|
||||
## Objective
|
||||
|
||||
Integrate an Expo-based React Native mobile application into the `cloud-reader` monorepo, sharing types, API client patterns, and configuration with the existing web frontend.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
cloud-reader/
|
||||
├── mobile/ # Expo React Native app
|
||||
│ ├── package.json
|
||||
│ ├── app.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── babel.config.js
|
||||
│ ├── App.tsx # Root component
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client (mirrors frontend/src/api/ pattern)
|
||||
│ │ │ ├── client.ts # Axios instance + JWT interceptor
|
||||
│ │ │ ├── books.ts # Book API calls
|
||||
│ │ │ └── annotations.ts
|
||||
│ │ ├── screens/ # Screen-level components
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ ├── navigation/ # React Navigation setup
|
||||
│ │ ├── context/ # Auth context, etc.
|
||||
│ │ ├── hooks/ # Custom hooks
|
||||
│ │ └── types/ # Mobile-specific types
|
||||
│ └── assets/
|
||||
├── packages/
|
||||
│ └── shared/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── src/
|
||||
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note)
|
||||
│ └── utils.ts # Shared utility functions
|
||||
└── package.json # Root — updated workspace config
|
||||
```
|
||||
|
||||
## Monorepo Workspace Config
|
||||
|
||||
Root `package.json` workspaces array updated to include `"mobile"`, `"packages/shared"` alongside existing `"frontend"` and `"backend"`.
|
||||
|
||||
## Shared `packages/shared`
|
||||
|
||||
- `@cloud-reader/shared` package published within the monorepo
|
||||
- Exports:
|
||||
- All domain types (`Book`, `BookSummary`, `Bookmark`, `Note`, `User`, `AnnotationEntry`, `PaginatedResponse`, `TokenResponse`)
|
||||
- API endpoint constants
|
||||
- Date formatting helpers
|
||||
- Validation utilities (email regex, password strength check)
|
||||
|
||||
## Mobile App Structure
|
||||
|
||||
### API Client (`mobile/src/api/client.ts`)
|
||||
- Axios instance configured with:
|
||||
- Base URL from environment variable (`EXPO_PUBLIC_API_URL`)
|
||||
- JWT token attachment via request interceptor
|
||||
- Token refresh response interceptor on 401
|
||||
- Uses `AsyncStorage` for token persistence (instead of `localStorage`)
|
||||
|
||||
### Navigation (`mobile/src/navigation/`)
|
||||
- React Navigation stack:
|
||||
1. `AuthStack` — Login, Register screens
|
||||
2. `MainTabs` — Library, Search, Settings tabs
|
||||
3. `BookReader` — Full-screen reading view
|
||||
|
||||
### Key Screens
|
||||
| Screen | Route | Purpose |
|
||||
|--------|-------|---------|
|
||||
| Login | `Auth/Login` | Email/password login |
|
||||
| Register | `Auth/Register` | User registration |
|
||||
| Library | `Main/Library` | Book list with filtering |
|
||||
| BookDetail | `Main/BookDetail` | Book metadata + actions |
|
||||
| Reader | `Reader/View` | EPUB/PDF rendering |
|
||||
| Search | `Main/Search` | Book discovery |
|
||||
| Settings | `Main/Settings` | Profile, theme, download mgmt |
|
||||
|
||||
## Backend Changes Required
|
||||
|
||||
None. The existing Django REST API already serves all endpoints needed by the mobile app. The mobile app communicates with the same backend via the shared API base URL.
|
||||
|
||||
## Docker
|
||||
|
||||
No changes to `docker-compose.yml` needed — the mobile app runs on-device or via Expo Go, not inside Docker.
|
||||
|
||||
## CI/CD Considerations
|
||||
|
||||
The monorepo structure supports a single pipeline that can:
|
||||
- `yarn install` at root (installs all workspaces)
|
||||
- `yarn workspace @cloud-reader/shared build`
|
||||
- `yarn workspace @cloud-reader/mobile build` (Expo EAS for mobile builds)
|
||||
- `yarn workspace @cloud-reader/frontend build` (Vite for web builds)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user