Compare commits

..
Author SHA1 Message Date
Marko e80c2673ef feat: implement reading pace notifications (US #33)
Backend:
- New hermes app with ReadingGroup, GroupMeeting, GroupMembership models
- NotificationPreference (per-user: enable/disable, frequency daily/weekly)
- PaceNotification model for tracking sent/dismissed notifications
- Pace calculation service comparing current progress vs meeting targets
- API endpoints: pace status, notification preferences CRUD, reminders, dismiss
- Ahead/behind/on_track/completed status determination

Frontend:
- PaceNotification component for in-app alerts (behind/ahead/on_track statuses)
- ReadingPaceBanner — persistent banner in reader view
- Notification settings in Settings page (enable/disable, frequency, reminders)
- API client module (groupsApi) for all notification endpoints
2026-06-20 19:33:41 +00:00
crisleo94 26c5f6f06b Merge pull request 'feat: implement mobile reader' (#27) from feat-mobile-reader into main
Reviewed-on: #27
2026-06-20 18:34:58 +00:00
crisleo94 22ded87250 feat: implement mobile reader
- implement mobile epub reader
2026-06-20 13:33:44 -05:00
crisleo94 84c0497f21 Merge pull request 'Feature/uv implementation' (#26) from feature/uv-implementation into main
Reviewed-on: #26
2026-06-04 12:10:51 +00:00
crisleo94 724eae1142 fix: remove binary files 2026-06-04 06:45:27 -05:00
crisleo94 654cfec147 fix: cleanup 2026-06-04 06:42:27 -05:00
crisleo94 f989b144cf fix: pdf bookmark
for pdfs only select pages not text
2026-06-03 23:01:12 -05:00
crisleo94 f091386974 feat: pdf reader 2026-06-03 22:46:38 -05:00
crisleo94 d9c66e68a6 fix: floating bookmarks
- update floating bookmarks
- fix search suggestions
- fix filters
2026-06-03 22:33:24 -05:00
crisleo94 054e7689bd feat: floating bookmarks
add floating bookmarks
2026-06-03 22:19:19 -05:00
crisleo94 6b4c0c43f8 feat: uv config other feats
- add uv configuration for the backend
- update frontend to make auth work
- add new auth endpoints
- add bookmars feat
- add reader feat
2026-06-03 22:06:01 -05:00
crisleo94 730c748f5f Merge pull request 'Implement: Customizable Mobile Reading Experience' (#25) from feature/customizable-mobile-reading-experience into main
Reviewed-on: #25
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 13:48:05 +00:00
Marko (Hermes Implementer) 7cdc3d0969 Remove stale web/ directory — content already ported to frontend/
Reid's second review flagged that the old web/ files still had:
- XSS vulnerability (no DOMPurify)
- CSS typo (landascape)
- API endpoint mismatches

All fix: delete web/ entirely. The correct, sanitized, fixed versions
already live in frontend/src/.
2026-05-29 07:04:53 +00:00
Marko (Hermes Implementer) a7864bf5cd Address Reid's PR #25 review comments
🔴 Blocking fixes:
- Add reader app migration (apps/reader/migrations/0001_initial.py)
- Remove duplicate ReadingSettings from apps.books to resolve model clash
  with apps.reader.ReadingSettings (keep richer reader version)
- Redirect books serializers/views to use reader app's ReadingSettings
- Update frontend API endpoints to match main's backend routes:
  /api/books/ebooks/{id}/toc/ (chapters)
  /api/books/ebooks/{id}/content/?page=N (chapter content)
  /api/books/ebooks/{id}/progress/ (GET/PATCH progress)
- Add DOMPurify sanitization for dangerouslySetInnerHTML (XSS fix)

🟡 Non-blocking fixes:
- Fix CSS typo: landascape -> landscape in reader.css
- Add null-safe fallback for book.title in ReadingPage
2026-05-29 06:37:58 +00:00
Marko (Hermes Implementer) edac7cb08a Merge branch 'main' into feature/customizable-mobile-reading-experience
Resolve merge conflicts:
- backend/apps/books/: Keep main's models (EBook, BookChapter, etc.)
- frontend/src/App.tsx: Keep /read/:id route + main's all routes
- web/ files: Accept deletion (content ported to frontend/)
2026-05-29 05:59:10 +00:00
Marko (Hermes Implementer) 74fdae61db Resolve merge conflicts for PR #25: customizable mobile reading experience
- Backend: Replace PR's Book-only models with main's full models
  (EBook, BookChapter, DownloadRecord, ReadingProgress, ReadingSettings)
- Backend: Add reader app (ReadingSettings model, serializer, view, URL)
- Frontend: Port reader feature files from web/ to frontend/ (api, hooks,
  components/reader/, pages/ReadingPage, types/reader, reader.css)
- Frontend: Add /read/:id route to App.tsx for chapter-based reading view
- All imports adjusted for frontend/src directory structure
2026-05-29 05:53:14 +00:00
crisleo94 3f626259e8 Merge pull request 'Implement: US: Mobile Book Search and Discovery' (#24) from feature/mobile-search-discovery into main
Reviewed-on: #24
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 05:13:02 +00:00
Marko (Hermes Implementer) ade810a013 fix: address PR #24 review comments - SpeechRecognition types, CSS hover over direct DOM, shared useDebounce 2026-05-29 05:10:06 +00:00
crisleo94 6a223d7237 Merge branch 'main' into feature/mobile-search-discovery 2026-05-29 04:50:59 +00:00
crisleo94 656d20879f Merge pull request 'Implement: US: Integrate Expo Mobile Application into Monorepo (#16)' (#23) from feature/expo-integration into main
Reviewed-on: #23
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 04:50:49 +00:00
crisleo94 2eeb3fb7d6 Merge pull request 'Fix: Missing API methods for Book Search and Discovery (re-opened #5)' (#14) from fix/missing-api-methods into main
Reviewed-on: #14
Reviewed-by: crisleo94 <cristhian.reinoso@codescripters.org>
2026-05-29 04:25:37 +00:00
Marko (Hermes Implementer) 658f77a746 feat: mobile book search and discovery with voice search, suggestions, and responsive layout 2026-05-29 02:55:50 +00:00
Marko (Hermes Implementer) b29ea8211c feat: customizable mobile reading experience
- Backend: Chapter, ReadingProgress, ReadingSettings models
- Backend: Chapter API (TOC + content), progress tracking, settings CRUD
- Frontend: ReadingPage with chapter navigation
- Frontend: TableOfContents drawer
- Frontend: ReadingSettingsPanel (theme, font, size, orientation)
- Frontend: Custom hooks for settings, chapters, progress tracking
- CSS: Mobile-first reading view with sepia/dark/light/paper themes
- Route: /reader/:bookId reading view from book detail page
- Docs: 001-customizable-mobile-reading-experience.md
2026-05-29 02:55:36 +00:00
Marko (Hermes Implementer) fa82fab44a feat: Integrate Expo mobile application into monorepo (#16)
- Add mobile/ directory with Expo React Native project
- Create API client with JWT auth and token refresh using AsyncStorage
- Implement AuthContext for login/register/logout flow
- Add screens: Login, Register, Library, Search, Settings
- Set up React Navigation with AuthStack and MainTabs
- Create packages/shared/ with shared types and utilities
- Add shared validation utilities (email, password strength)
- Update root package.json workspaces to include mobile + shared
- Add spec document docs/backend/009-expo-integration.md
2026-05-29 02:50:09 +00:00
Marko (Hermes Implementer) 670101b61e fix: add missing book API methods and types (searchBooks, getBook, getGenres, getAuthors) 2026-05-29 00:08:32 +00:00
markoandreid 332b539880 Implement: US: Book Search and Discovery (#13)
Reviewed and merged by Reid (Hermes Reviewer)

Co-authored-by: crisleo-hermes <hermes@codescripters.org>
Co-committed-by: crisleo-hermes <hermes@codescripters.org>
2026-05-26 06:21:32 +00:00
markoandreid f84a593c5d Implement: Refactor: Consolidate Duplicate Backend and Frontend Implementations (#11)
Reviewed and merged by Reid (Hermes Reviewer)

Co-authored-by: crisleo-hermes <hermes@codescripters.org>
Co-committed-by: crisleo-hermes <hermes@codescripters.org>
2026-05-26 03:54:25 +00:00
Marko (Hermes Implementer) 3b5b301e42 feat: bookmarks and notes management
- Backend: Django REST Framework API with Bookmark and Note models
  - ViewSets with user-scoped querysets and select_related for N+1 prevention
  - Create/List/Detail/Update/Delete endpoints
  - Batch delete operations
  - Unique constraint on user+book+page for bookmarks
  - IsOwner permission class for object-level access control
  - Full serializer validation (page > 0, non-empty content, duplicate check)
  - 30+ pytest-django tests covering CRUD, auth, filtering, edge cases

- Frontend: React TypeScript components
  - AnnotationsContext with useReducer for state management
  - BookmarkList, NoteList, AddAnnotationForm, AnnotationsDashboard
  - Inline note editing with immediate save
  - Batch delete support
  - API client with JWT auto-refresh interceptors
  - Paginated query hook for infinite scroll support
  - Responsive CSS with loading/empty states

- Infrastructure: Django project with custom User model, JWT auth, CORS
  - PostgreSQL database models with proper FK and indexes
  - Django admin configuration for all models
2026-05-26 00:50:06 +00:00
277 changed files with 58320 additions and 3341 deletions
+10
View File
@@ -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
View File
@@ -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/
+105
View File
@@ -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
```
+93
View File
@@ -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.
+16
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.12
+18
View File
@@ -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"]
+27
View File
@@ -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",)
+7
View File
@@ -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),
),
]
+100
View File
@@ -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}"
+8
View File
@@ -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
+124
View File
@@ -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)
+166
View File
@@ -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
+12
View File
@@ -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)),
]
+83
View File
@@ -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)
View File
+9
View File
@@ -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")
+7
View File
@@ -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')},
),
]
@@ -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),
),
]
+156
View File
@@ -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
+231
View File
@@ -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()]
+78
View File
@@ -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
+304
View File
@@ -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
+255
View File
@@ -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)
+13
View File
@@ -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)),
]
+326
View File
@@ -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)
View File
+38
View File
@@ -0,0 +1,38 @@
from django.contrib import admin
from apps.groups.models import (
GroupMeeting,
GroupMembership,
NotificationPreference,
PaceNotification,
ReadingGroup,
)
@admin.register(ReadingGroup)
class ReadingGroupAdmin(admin.ModelAdmin):
list_display = ["name", "created_by", "created_at"]
search_fields = ["name"]
@admin.register(GroupMembership)
class GroupMembershipAdmin(admin.ModelAdmin):
list_display = ["user", "group", "role", "joined_at"]
list_filter = ["role"]
@admin.register(GroupMeeting)
class GroupMeetingAdmin(admin.ModelAdmin):
list_display = ["group", "title", "target_section_label", "scheduled_at"]
list_filter = ["group"]
@admin.register(NotificationPreference)
class NotificationPreferenceAdmin(admin.ModelAdmin):
list_display = ["user", "pace_notifications_enabled", "reminder_enabled", "frequency"]
@admin.register(PaceNotification)
class PaceNotificationAdmin(admin.ModelAdmin):
list_display = ["user", "meeting", "status", "created_at"]
list_filter = ["status"]
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class GroupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.groups"
verbose_name = "Reading Groups"
label = "groups"
@@ -0,0 +1,109 @@
# Generated by Django 5.1.7 on 2026-06-20 19:22
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'),
('users', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='NotificationPreference',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='notification_preference', serialize=False, to=settings.AUTH_USER_MODEL)),
('pace_notifications_enabled', models.BooleanField(default=True)),
('reminder_enabled', models.BooleanField(default=True)),
('frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly')], default='daily', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Notification Preference',
'verbose_name_plural': 'Notification Preferences',
'db_table': 'groups_notification_preference',
},
),
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)),
('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reading_groups', to='books.book')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_groups', to=settings.AUTH_USER_MODEL)),
('ebook', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reading_groups', to='books.ebook')),
],
options={
'verbose_name': 'Reading Group',
'verbose_name_plural': 'Reading Groups',
'db_table': 'groups_reading_group',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='GroupMeeting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(blank=True, default='', max_length=512)),
('scheduled_at', models.DateTimeField(db_index=True)),
('target_section', models.PositiveIntegerField(default=0, help_text='Target chapter/section index to reach by this meeting')),
('target_section_label', models.CharField(blank=True, default='', help_text="Human-readable label for the target section (e.g. 'Section 3')", max_length=512)),
('created_at', models.DateTimeField(auto_now_add=True)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='groups.readinggroup')),
],
options={
'verbose_name': 'Group Meeting',
'verbose_name_plural': 'Group Meetings',
'db_table': 'groups_meeting',
'ordering': ['scheduled_at'],
},
),
migrations.CreateModel(
name='PaceNotification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('dismissed', 'Dismissed')], default='pending', max_length=16)),
('current_section', models.PositiveIntegerField(default=0)),
('target_section', models.PositiveIntegerField(default=0)),
('ahead_behind_delta', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('ebook', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pace_notifications', to='books.ebook')),
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pace_notifications', to='groups.groupmeeting')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pace_notifications', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Pace Notification',
'verbose_name_plural': 'Pace Notifications',
'db_table': 'groups_pace_notification',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['user', 'meeting', 'ebook'], name='groups_pace_user_id_18e929_idx')],
},
),
migrations.CreateModel(
name='GroupMembership',
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)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')),
],
options={
'verbose_name': 'Group Membership',
'verbose_name_plural': 'Group Memberships',
'db_table': 'groups_membership',
'indexes': [models.Index(fields=['user', 'group'], name='groups_memb_user_id_9cda7e_idx')],
'unique_together': {('user', 'group')},
},
),
]
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
from django.conf import settings
from django.db import models
class FrequencyChoice(models.TextChoices):
DAILY = "daily", "Daily"
WEEKLY = "weekly", "Weekly"
class MembershipRole(models.TextChoices):
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
class NotificationStatus(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
DISMISSED = "dismissed", "Dismissed"
class ReadingGroup(models.Model):
"""A group of users reading a book together with scheduled meetings."""
name = models.CharField(max_length=256, db_index=True)
description = models.TextField(blank=True, default="")
book = models.ForeignKey(
"books.Book",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="reading_groups",
)
ebook = models.ForeignKey(
"books.EBook",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="reading_groups",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="created_groups",
)
created_at = models.DateTimeField(auto_now_add=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 GroupMembership(models.Model):
"""User membership in a reading group."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="group_memberships",
)
group = models.ForeignKey(
ReadingGroup,
on_delete=models.CASCADE,
related_name="memberships",
)
role = models.CharField(
max_length=16,
choices=MembershipRole.choices,
default=MembershipRole.MEMBER,
)
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_membership"
verbose_name = "Group Membership"
verbose_name_plural = "Group Memberships"
unique_together = [("user", "group")]
indexes = [models.Index(fields=["user", "group"])]
def __str__(self) -> str:
return f"{self.user} in {self.group}"
class GroupMeeting(models.Model):
"""A scheduled meeting for a reading group with a target section."""
group = models.ForeignKey(
ReadingGroup,
on_delete=models.CASCADE,
related_name="meetings",
)
title = models.CharField(max_length=512, blank=True, default="")
scheduled_at = models.DateTimeField(db_index=True)
target_section = models.PositiveIntegerField(
default=0,
help_text="Target chapter/section index to reach by this meeting",
)
target_section_label = models.CharField(
max_length=512,
blank=True,
default="",
help_text="Human-readable label for the target section (e.g. 'Section 3')",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_meeting"
verbose_name = "Group Meeting"
verbose_name_plural = "Group Meetings"
ordering = ["scheduled_at"]
def __str__(self) -> str:
return f"{self.group.name}{self.title or self.target_section_label} ({self.scheduled_at.date()})"
class NotificationPreference(models.Model):
"""Per-user notification settings for reading pace alerts."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notification_preference",
primary_key=True,
)
pace_notifications_enabled = models.BooleanField(default=True)
reminder_enabled = models.BooleanField(default=True)
frequency = models.CharField(
max_length=16,
choices=FrequencyChoice.choices,
default=FrequencyChoice.DAILY,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "groups_notification_preference"
verbose_name = "Notification Preference"
verbose_name_plural = "Notification Preferences"
def __str__(self) -> str:
return f"{self.user} — pace={'on' if self.pace_notifications_enabled else 'off'}, freq={self.frequency}"
class PaceNotification(models.Model):
"""Tracks reading pace notifications sent to users to avoid duplicates."""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="pace_notifications",
)
meeting = models.ForeignKey(
GroupMeeting,
on_delete=models.CASCADE,
related_name="pace_notifications",
)
ebook = models.ForeignKey(
"books.EBook",
on_delete=models.CASCADE,
related_name="pace_notifications",
null=True,
blank=True,
)
status = models.CharField(
max_length=16,
choices=NotificationStatus.choices,
default=NotificationStatus.PENDING,
)
current_section = models.PositiveIntegerField(default=0)
target_section = models.PositiveIntegerField(default=0)
ahead_behind_delta = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "groups_pace_notification"
verbose_name = "Pace Notification"
verbose_name_plural = "Pace Notifications"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "meeting", "ebook"]),
]
def __str__(self) -> str:
return f"{self.user} — meeting {self.meeting_id}: {self.get_status_display()}"
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from rest_framework import serializers
from apps.groups.models import (
FrequencyChoice,
GroupMeeting,
GroupMembership,
NotificationPreference,
PaceNotification,
ReadingGroup,
)
from apps.groups.services.pace import PaceStatus
class NotificationPreferenceSerializer(serializers.ModelSerializer):
class Meta:
model = NotificationPreference
fields = [
"pace_notifications_enabled",
"reminder_enabled",
"frequency",
]
def validate_frequency(self, value: str) -> str:
valid = {choice[0] for choice in FrequencyChoice.choices}
if value not in valid:
raise serializers.ValidationError(
f"Frequency must be one of: {', '.join(valid)}"
)
return value
class GroupMeetingSerializer(serializers.ModelSerializer):
group_name = serializers.CharField(source="group.name", read_only=True)
class Meta:
model = GroupMeeting
fields = [
"id",
"group",
"group_name",
"title",
"scheduled_at",
"target_section",
"target_section_label",
"created_at",
]
read_only_fields = ["id", "created_at"]
def validate_target_section(self, value: int) -> int:
if value < 0:
raise serializers.ValidationError("Target section must be non-negative.")
return value
class ReadingGroupSerializer(serializers.ModelSerializer):
member_count = serializers.SerializerMethodField()
meeting_count = serializers.SerializerMethodField()
class Meta:
model = ReadingGroup
fields = [
"id",
"name",
"description",
"book",
"ebook",
"created_by",
"created_at",
"member_count",
"meeting_count",
]
read_only_fields = ["id", "created_by", "created_at"]
def get_member_count(self, obj: ReadingGroup) -> int:
return obj.memberships.count()
def get_meeting_count(self, obj: ReadingGroup) -> int:
return obj.meetings.count()
class GroupMembershipSerializer(serializers.ModelSerializer):
user_email = serializers.CharField(source="user.email", read_only=True)
group_name = serializers.CharField(source="group.name", read_only=True)
class Meta:
model = GroupMembership
fields = ["id", "user", "user_email", "group", "group_name", "role", "joined_at"]
read_only_fields = ["id", "joined_at"]
class PaceNotificationSerializer(serializers.ModelSerializer):
status_display = serializers.CharField(source="get_status_display", read_only=True)
class Meta:
model = PaceNotification
fields = [
"id",
"user",
"meeting",
"ebook",
"status",
"status_display",
"current_section",
"target_section",
"ahead_behind_delta",
"created_at",
]
read_only_fields = ["id", "created_at"]
class PaceStatusSerializer(serializers.Serializer):
meeting_id = serializers.IntegerField()
meeting_title = serializers.CharField()
scheduled_at = serializers.CharField()
target_section = serializers.IntegerField()
target_section_label = serializers.CharField()
current_section = serializers.IntegerField()
ahead_behind_delta = serializers.IntegerField()
status = serializers.CharField()
days_until_meeting = serializers.IntegerField()
sections_to_catch_up = serializers.IntegerField()
ebook_id = serializers.IntegerField()
ebook_title = serializers.CharField()
class ReminderSerializer(serializers.Serializer):
meeting_id = serializers.IntegerField()
meeting_title = serializers.CharField()
scheduled_at = serializers.CharField()
target_section = serializers.IntegerField()
target_section_label = serializers.CharField()
group_name = serializers.CharField()
hours_until = serializers.IntegerField()
ebook_id = serializers.IntegerField()
ebook_title = serializers.CharField()
+219
View File
@@ -0,0 +1,219 @@
"""Pace calculation service for reading progress vs meeting targets."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import TypedDict
from django.db.models import QuerySet
from django.utils import timezone
from apps.books.models import EBook, ReadingProgress
from apps.groups.models import (
GroupMeeting,
GroupMembership,
NotificationPreference,
PaceNotification,
NotificationStatus,
)
logger = logging.getLogger(__name__)
class PaceStatus(TypedDict):
meeting_id: int
meeting_title: str
scheduled_at: str
target_section: int
target_section_label: str
current_section: int
ahead_behind_delta: int
status: str # "ahead", "behind", "on_track", "completed"
days_until_meeting: int
sections_to_catch_up: int
ebook_id: int
ebook_title: str
def get_relevant_ebooks_for_user(user) -> QuerySet[EBook]:
"""Return ebooks that belong to groups the user is a member of."""
group_ids = (
GroupMembership.objects.filter(user=user)
.values_list("group_id", flat=True)
)
return (
EBook.objects.filter(reading_groups__in=group_ids)
.select_related("reading_progress")
.prefetch_related("chapters")
.distinct()
)
def calculate_pace(
user,
ebook: EBook | None = None,
meeting: GroupMeeting | None = None,
) -> list[PaceStatus]:
"""Calculate reading pace for a user's upcoming meetings.
Compares the user's current reading progress (chapter index) against
the target section for each upcoming meeting within 3 days.
"""
now = timezone.now()
three_days_from_now = now + timedelta(days=3)
# Find upcoming meetings for groups the user belongs to
user_group_ids = (
GroupMembership.objects.filter(user=user)
.values_list("group_id", flat=True)
)
meetings_qs = GroupMeeting.objects.filter(
group_id__in=user_group_ids,
scheduled_at__gte=now,
scheduled_at__lte=three_days_from_now,
).select_related("group", "group__ebook")
if meeting:
meetings_qs = meetings_qs.filter(pk=meeting.pk)
meetings = list(meetings_qs)
results: list[PaceStatus] = []
for m in meetings:
group_ebook = m.group.ebook
ebooks_to_check: list[EBook] = []
if ebook:
ebooks_to_check = [ebook]
elif group_ebook:
ebooks_to_check = [group_ebook]
else:
# Check all ebooks in the group
ebooks_to_check = list(
EBook.objects.filter(reading_groups=m.group)
.select_related("reading_progress")
.prefetch_related("chapters")
)
for eb in ebooks_to_check:
pace = _compute_single_pace(user, m, eb, now)
if pace:
results.append(pace)
return results
def _compute_single_pace(
user,
meeting: GroupMeeting,
ebook: EBook,
now: datetime,
) -> PaceStatus | None:
"""Compute pace for one user + meeting + ebook combination."""
try:
progress = ebook.reading_progress
except ReadingProgress.DoesNotExist:
# No progress yet — user hasn't started reading
return None
# Current section: based on last_page (chapter index) or computed from progress %
if progress.last_page > 0:
current_section = progress.last_page
elif progress.current_position > 0:
chapter_count = ebook.chapters.count() or ebook.page_count or 1
current_section = max(1, int((progress.current_position / 100) * chapter_count))
else:
current_section = 0
target_section = meeting.target_section or 0
if target_section == 0:
return None
delta = current_section - target_section
days_until = max(0, (meeting.scheduled_at - now).days)
if current_section >= target_section:
if current_section >= target_section and progress.current_position > 0:
status = "completed"
else:
status = "ahead"
elif delta >= -1:
status = "on_track"
else:
status = "behind"
return PaceStatus(
meeting_id=meeting.pk,
meeting_title=meeting.title or meeting.target_section_label,
scheduled_at=meeting.scheduled_at.isoformat(),
target_section=target_section,
target_section_label=meeting.target_section_label,
current_section=current_section,
ahead_behind_delta=delta,
status=status,
days_until_meeting=days_until,
sections_to_catch_up=max(0, target_section - current_section),
ebook_id=ebook.pk,
ebook_title=ebook.title,
)
def should_send_notification(
user,
meeting: GroupMeeting,
ebook: EBook,
) -> bool:
"""Check if a pace notification should be sent based on user preferences."""
pref, _ = NotificationPreference.objects.get_or_create(user=user)
if not pref.pace_notifications_enabled:
return False
# Check if notification was already sent today
today = timezone.now().date()
existing = PaceNotification.objects.filter(
user=user,
meeting=meeting,
ebook=ebook,
created_at__date=today,
).exclude(status=NotificationStatus.DISMISSED)
if existing.exists():
return False
# Frequency check
if pref.frequency == "weekly":
week_ago = today - timedelta(days=7)
recent = PaceNotification.objects.filter(
user=user,
meeting=meeting,
ebook=ebook,
created_at__date__gte=week_ago,
).exclude(status=NotificationStatus.DISMISSED)
if recent.exists():
return False
return True
def get_reminder_meetings(user) -> list[GroupMeeting]:
"""Get meetings that are within 24 hours for reminder notifications."""
now = timezone.now()
tomorrow = now + timedelta(days=1)
user_group_ids = (
GroupMembership.objects.filter(user=user)
.values_list("group_id", flat=True)
)
return list(
GroupMeeting.objects.filter(
group_id__in=user_group_ids,
scheduled_at__gte=now,
scheduled_at__lte=tomorrow,
)
.select_related("group", "group__ebook")
.order_by("scheduled_at")
)
+23
View File
@@ -0,0 +1,23 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from apps.groups.views import (
GroupMeetingViewSet,
ReadingGroupViewSet,
dismiss_pace_notification_view,
notification_preferences_view,
pace_status_view,
reminder_view,
)
router = DefaultRouter()
router.register(r"groups", ReadingGroupViewSet, basename="reading-group")
router.register(r"meetings", GroupMeetingViewSet, basename="group-meeting")
urlpatterns = [
path("", include(router.urls)),
path("notifications/preferences/", notification_preferences_view, name="notification-preferences"),
path("notifications/pace/", pace_status_view, name="pace-status"),
path("notifications/dismiss/", dismiss_pace_notification_view, name="dismiss-pace"),
path("notifications/reminders/", reminder_view, name="meeting-reminders"),
]
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from django.db.models import QuerySet
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from apps.books.models import EBook
from apps.groups.models import (
GroupMeeting,
GroupMembership,
NotificationPreference,
PaceNotification,
NotificationStatus,
ReadingGroup,
)
from apps.groups.serializers import (
GroupMeetingSerializer,
GroupMembershipSerializer,
NotificationPreferenceSerializer,
PaceNotificationSerializer,
PaceStatusSerializer,
ReadingGroupSerializer,
ReminderSerializer,
)
from apps.groups.services.pace import (
calculate_pace,
get_reminder_meetings,
should_send_notification,
)
class IsGroupMember(permissions.BasePermission):
"""Only allow group members to access group resources."""
def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool:
return obj.memberships.filter(user=request.user).exists()
class ReadingGroupViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsGroupMember]
serializer_class = ReadingGroupSerializer
def get_queryset(self) -> QuerySet[ReadingGroup]:
return (
ReadingGroup.objects.filter(memberships__user=self.request.user)
.prefetch_related("memberships", "meetings")
.distinct()
)
def perform_create(self, serializer) -> None:
group = serializer.save(created_by=self.request.user)
GroupMembership.objects.create(
user=self.request.user,
group=group,
role="admin",
)
@action(detail=False, methods=["get"], url_path="all")
def all_groups(self, request: Request) -> Response:
"""Return all groups (for discovery)."""
qs = ReadingGroup.objects.all().prefetch_related("memberships", "meetings")
page = self.paginate_queryset(qs)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data)
class GroupMeetingViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
serializer_class = GroupMeetingSerializer
def get_queryset(self) -> QuerySet[GroupMeeting]:
user_group_ids = (
GroupMembership.objects.filter(user=self.request.user)
.values_list("group_id", flat=True)
)
return GroupMeeting.objects.filter(group_id__in=user_group_ids).select_related(
"group"
)
def perform_create(self, serializer) -> None:
group = serializer.validated_data["group"]
if not group.memberships.filter(user=self.request.user).exists():
raise permissions.PermissionDenied("You must be a group member to create meetings.")
serializer.save()
@action(detail=False, methods=["get"], url_path="upcoming")
def upcoming(self, request: Request) -> Response:
"""Return upcoming meetings for the user's groups (within 3 days)."""
from django.utils import timezone
from datetime import timedelta
now = timezone.now()
three_days = now + timedelta(days=3)
qs = self.get_queryset().filter(
scheduled_at__gte=now,
scheduled_at__lte=three_days,
)
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data)
@api_view(["GET", "PATCH"])
@permission_classes([IsAuthenticated])
def notification_preferences_view(request: Request) -> Response:
"""Get or update notification preferences for the current user."""
pref, _created = NotificationPreference.objects.get_or_create(user=request.user)
if request.method == "GET":
serializer = NotificationPreferenceSerializer(pref)
return Response(serializer.data)
serializer = NotificationPreferenceSerializer(pref, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def pace_status_view(request: Request) -> Response:
"""Get reading pace status for the current user's upcoming meetings."""
ebook_id = request.query_params.get("ebook_id")
ebook: EBook | None = None
if ebook_id:
try:
ebook = EBook.objects.select_related("reading_progress").prefetch_related("chapters").get(
pk=ebook_id, user=request.user
)
except EBook.DoesNotExist:
return Response(
{"error": "EBook not found."}, status=status.HTTP_404_NOT_FOUND
)
pace_data = calculate_pace(request.user, ebook=ebook)
serializer = PaceStatusSerializer(pace_data, many=True)
return Response(serializer.data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def dismiss_pace_notification_view(request: Request) -> Response:
"""Dismiss a pace notification so it won't show again."""
meeting_id = request.data.get("meeting_id")
ebook_id = request.data.get("ebook_id")
if not meeting_id or not ebook_id:
return Response(
{"error": "meeting_id and ebook_id are required."},
status=status.HTTP_400_BAD_REQUEST,
)
PaceNotification.objects.filter(
user=request.user,
meeting_id=meeting_id,
ebook_id=ebook_id,
status=NotificationStatus.PENDING,
).update(status=NotificationStatus.DISMISSED)
return Response({"status": "dismissed"})
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def reminder_view(request: Request) -> Response:
"""Get pre-meeting reminders for meetings within 24 hours."""
pref, _ = NotificationPreference.objects.get_or_create(user=request.user)
if not pref.reminder_enabled:
return Response([])
meetings = get_reminder_meetings(request.user)
now_dt = __import__("django").utils.timezone.now()
reminders = []
for mtg in meetings:
group_ebook = mtg.group.ebook
ebook: EBook | None = group_ebook
if not ebook:
ebooks = list(
EBook.objects.filter(reading_groups=mtg.group).select_related("reading_progress")[:1]
)
ebook = ebooks[0] if ebooks else None
if not ebook:
continue
hours_until = max(0, int((mtg.scheduled_at - now_dt).total_seconds() / 3600))
reminders.append({
"meeting_id": mtg.pk,
"meeting_title": mtg.title or mtg.target_section_label,
"scheduled_at": mtg.scheduled_at.isoformat(),
"target_section": mtg.target_section,
"target_section_label": mtg.target_section_label,
"group_name": mtg.group.name,
"hours_until": hours_until,
"ebook_id": ebook.pk,
"ebook_title": ebook.title,
})
serializer = ReminderSerializer(reminders, many=True)
return Response(serializer.data)
View File
+7
View File
@@ -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',
},
),
]
+53
View File
@@ -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)"
+63
View File
@@ -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
+7
View File
@@ -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"),
]
+33
View File
@@ -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)
View File
+11
View File
@@ -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")
+7
View File
@@ -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()),
],
),
]
+13
View File
@@ -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
+93
View File
@@ -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
+11
View File
@@ -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"),
]
+14
View File
@@ -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
-16
View File
@@ -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"]
-5
View File
@@ -1,5 +0,0 @@
from django.apps import AppConfig
class BooksConfig(AppConfig):
name = 'books'
-41
View File
@@ -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')],
},
),
]
-66
View File
@@ -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"])
-95
View File
@@ -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
-325
View File
@@ -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
-13
View File
@@ -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)),
]
-91
View File
@@ -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,
})
-16
View File
@@ -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()
+159
View File
@@ -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.annotations",
"apps.reader",
"apps.groups",
]
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
View File
@@ -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()
-33
View File
@@ -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
View File
@@ -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/annotations/", include("apps.annotations.urls")),
path("api/reader/", include("apps.reader.urls")),
path("api/groups/", include("apps.groups.urls")),
]
if settings.DEBUG:
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+1 -10
View File
@@ -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
View File
@@ -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()
+25
View File
@@ -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
View File
@@ -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
+15 -6
View File
@@ -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
+673
View File
@@ -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" },
]
+57
View File
@@ -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:
+117
View File
@@ -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.
-84
View File
@@ -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`.
+107
View File
@@ -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
+73
View File
@@ -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 (0100) 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
+56
View File
@@ -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
+54
View File
@@ -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` (0100) 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 | — |
| `198` | — | 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
+83
View File
@@ -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
-82
View File
@@ -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
+99
View File
@@ -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
+96
View File
@@ -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