Archived
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
724eae1142 | ||
|
|
654cfec147 | ||
|
|
f989b144cf | ||
|
|
f091386974 | ||
|
|
d9c66e68a6 | ||
|
|
054e7689bd | ||
|
|
6b4c0c43f8 | ||
|
|
730c748f5f | ||
|
|
7cdc3d0969 | ||
|
|
a7864bf5cd | ||
|
|
edac7cb08a | ||
|
|
74fdae61db | ||
|
|
3f626259e8 | ||
|
|
ade810a013 | ||
|
|
6a223d7237 | ||
|
|
656d20879f | ||
|
|
2eeb3fb7d6 | ||
|
|
658f77a746 | ||
|
|
b29ea8211c | ||
|
|
fa82fab44a | ||
|
|
670101b61e | ||
|
|
332b539880 | ||
|
|
f84a593c5d | ||
|
|
3b5b301e42 |
@@ -0,0 +1,10 @@
|
|||||||
|
POSTGRES_DB=postres
|
||||||
|
POSTGRES_USER=root
|
||||||
|
POSTGRES_PASSWORD=root
|
||||||
|
|
||||||
|
DJANGO_SECRET_KEY=changeme
|
||||||
|
DJANGO_DEBUG=True
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
VITE_API_URL=http://localhost:8000/api
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
name: Backend CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
paths:
|
|
||||||
- "backend/**"
|
|
||||||
- ".github/workflows/backend-ci.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "backend/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
|
||||||
env:
|
|
||||||
POSTGRES_DB: cloud_reader
|
|
||||||
POSTGRES_USER: cloud_reader
|
|
||||||
POSTGRES_PASSWORD: cloud_reader
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: backend
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python 3.12
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
cache: "pip"
|
|
||||||
cache-dependency-path: backend/requirements/dev.txt
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements/dev.txt
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Run migrations
|
|
||||||
run: python manage.py migrate
|
|
||||||
env:
|
|
||||||
DB_HOST: localhost
|
|
||||||
DB_NAME: cloud_reader
|
|
||||||
DB_USER: cloud_reader
|
|
||||||
DB_PASSWORD: cloud_reader
|
|
||||||
DB_PORT: 5432
|
|
||||||
SECRET_KEY: ci-test-secret-key-do-not-use-in-production
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: pytest
|
|
||||||
env:
|
|
||||||
DB_HOST: localhost
|
|
||||||
DB_NAME: cloud_reader
|
|
||||||
DB_USER: cloud_reader
|
|
||||||
DB_PASSWORD: cloud_reader
|
|
||||||
DB_PORT: 5432
|
|
||||||
SECRET_KEY: ci-test-secret-key-do-not-use-in-production
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
name: Frontend CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
paths:
|
|
||||||
- "frontend/**"
|
|
||||||
- "shared/**"
|
|
||||||
- ".github/workflows/frontend-ci.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "frontend/**"
|
|
||||||
- "shared/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node.js 20
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "yarn"
|
|
||||||
cache-dependency-path: yarn.lock
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Type check shared
|
|
||||||
run: yarn workspace @cloud-reader/shared typecheck
|
|
||||||
|
|
||||||
- name: Build shared
|
|
||||||
run: yarn workspace @cloud-reader/shared build
|
|
||||||
|
|
||||||
- name: Type check frontend
|
|
||||||
run: yarn workspace @cloud-reader/frontend typecheck
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
run: yarn workspace @cloud-reader/frontend build
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
name: Mobile CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
paths:
|
|
||||||
- "mobile/**"
|
|
||||||
- "shared/**"
|
|
||||||
- ".github/workflows/mobile-ci.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "mobile/**"
|
|
||||||
- "shared/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node.js 20
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "yarn"
|
|
||||||
cache-dependency-path: yarn.lock
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Type check shared
|
|
||||||
run: yarn workspace @cloud-reader/shared typecheck
|
|
||||||
|
|
||||||
- name: Build shared
|
|
||||||
run: yarn workspace @cloud-reader/shared build
|
|
||||||
|
|
||||||
- name: Type check mobile
|
|
||||||
run: yarn workspace @cloud-reader/mobile typecheck
|
|
||||||
+37
-45
@@ -1,48 +1,40 @@
|
|||||||
# Python
|
node_modules
|
||||||
__pycache__/
|
dist
|
||||||
*.py[cod]
|
frontend/dist
|
||||||
*.egg-info/
|
frontend/dist/assets
|
||||||
dist/
|
frontend/dist/assets/index.html
|
||||||
build/
|
frontend/dist/assets/index.html.gz
|
||||||
.eggs/
|
frontend/dist/assets/index.html.br
|
||||||
*.egg
|
frontend/dist/assets/index.html.brotli
|
||||||
.env
|
frontend/dist/assets/index.html.gzip
|
||||||
venv/
|
frontend/dist/assets/index.html.br
|
||||||
.venv/
|
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
|
*.sqlite3
|
||||||
|
*.log
|
||||||
# Node
|
*.env
|
||||||
node_modules/
|
*.DS_Store
|
||||||
.pnp
|
*.vscode
|
||||||
.pnp.js
|
*.idea
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Django
|
|
||||||
backend/media/
|
|
||||||
backend/staticfiles/
|
|
||||||
backend/**/migrations/
|
|
||||||
|
|
||||||
# Expo / React Native
|
|
||||||
mobile/.expo/
|
|
||||||
mobile/ios/Pods/
|
|
||||||
mobile/android/.gradle/
|
|
||||||
mobile/android/app/build/
|
|
||||||
mobile/android/build/
|
|
||||||
mobile/*.hprof
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env.local
|
|
||||||
.env.production
|
|
||||||
.env.development
|
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -1,195 +1,93 @@
|
|||||||
# Cloud Reader
|
# Cloud Reader
|
||||||
|
|
||||||
A modern eBook reader with web and mobile clients, powered by Django REST Framework.
|
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.
|
||||||
|
|
||||||
## Monorepo Structure
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
cloud-reader/
|
cloud-reader/
|
||||||
├── backend/ # Django API server (Python 3.12 + DRF)
|
├── backend/ # Django REST API (canonical backend)
|
||||||
│ ├── config/ # Django project settings
|
│ ├── config/ # Django project settings
|
||||||
│ ├── apps/ # Django applications
|
│ ├── apps/
|
||||||
│ │ ├── accounts/ # User authentication & profiles
|
│ │ ├── users/ # User auth (JWT)
|
||||||
│ │ ├── documents/ # Document management & uploads
|
│ │ ├── books/ # Books, e-books, reading progress, settings
|
||||||
│ │ ├── collections/# Document collections
|
│ │ └── annotations/ # Bookmarks and notes
|
||||||
│ │ └── reading/ # Bookmarks, highlights, reading progress
|
│ ├── manage.py
|
||||||
│ ├── requirements/ # pip dependency files
|
│ └── requirements.txt
|
||||||
│ ├── Dockerfile
|
├── frontend/ # React + Vite + TypeScript (web frontend)
|
||||||
│ └── manage.py
|
|
||||||
├── frontend/ # React web app (TypeScript + Vite)
|
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── pages/ # Route pages (lazy-loaded)
|
│ │ ├── api/ # API client (axios with JWT refresh)
|
||||||
│ │ ├── hooks/ # Custom React hooks
|
│ │ ├── components/ # Reusable components
|
||||||
│ │ ├── services/ # API client & auth service
|
│ │ ├── context/ # Auth and annotations context
|
||||||
│ │ ├── types/ # Frontend-specific types
|
│ │ ├── hooks/ # Custom hooks
|
||||||
│ │ └── styles/ # Global CSS
|
│ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings)
|
||||||
│ ├── Dockerfile
|
│ │ └── types/ # TypeScript type definitions
|
||||||
│ └── vite.config.ts
|
│ └── package.json
|
||||||
├── mobile/ # Expo/React Native mobile app
|
├── mobile/ # Expo React Native app (mobile frontend)
|
||||||
│ ├── app/ # Expo Router pages
|
│ ├── src/
|
||||||
│ ├── src/ # Mobile source code
|
│ │ ├── api/ # API client (axios with JWT refresh via AsyncStorage)
|
||||||
│ ├── app.json
|
│ │ ├── components/ # Reusable UI components
|
||||||
│ └── Dockerfile
|
│ │ ├── context/ # Auth context
|
||||||
├── shared/ # Shared TypeScript types & utilities
|
│ │ ├── hooks/ # Custom hooks
|
||||||
│ └── src/
|
│ │ ├── navigation/ # React Navigation (Auth stack + Main tabs)
|
||||||
│ └── index.ts # API response types, constants
|
│ │ ├── screens/ # Screen-level components (Login, Library, etc.)
|
||||||
├── .github/workflows/ # CI/CD pipelines
|
│ │ └── types/ # Mobile-specific types
|
||||||
└── package.json # Yarn workspace root
|
│ ├── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Quick Start
|
||||||
|
|
||||||
- **Python** 3.12+
|
### Docker (recommended)
|
||||||
- **Node.js** 20 LTS
|
```bash
|
||||||
- **Yarn** 4.x
|
cp env.example .env
|
||||||
- **PostgreSQL** 16
|
```
|
||||||
- **Expo CLI** (for mobile development)
|
```bash
|
||||||
|
docker compose up --build
|
||||||
---
|
```
|
||||||
|
- **Frontend:** http://localhost:5173
|
||||||
## Backend Setup
|
- **Backend API:** http://localhost:8000/api/
|
||||||
|
|
||||||
|
### Backend (standalone)
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
# Create virtual environment
|
|
||||||
python -m venv .venv
|
|
||||||
source .venv/bin/activate # Linux/macOS
|
|
||||||
# .venv\Scripts\activate # Windows
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pip install -r requirements/dev.txt
|
|
||||||
|
|
||||||
# Configure environment
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your PostgreSQL credentials
|
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
|
|
||||||
# Create admin user
|
|
||||||
python manage.py createsuperuser
|
|
||||||
|
|
||||||
# Start development server
|
|
||||||
python manage.py runserver
|
python manage.py runserver
|
||||||
```
|
```
|
||||||
|
|
||||||
The API will be available at `http://localhost:8000/`. Browse the API at `http://localhost:8000/api/schema/swagger-ui/`.
|
### Frontend (standalone)
|
||||||
|
|
||||||
### Backend Tests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd frontend
|
||||||
pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From monorepo root
|
|
||||||
yarn install
|
yarn install
|
||||||
|
yarn dev
|
||||||
# Start dev server (with API proxy)
|
|
||||||
yarn frontend:dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The frontend will be available at `http://localhost:5173/`. API requests under `/api/` are proxied to `http://localhost:8000/`.
|
### Mobile (Expo)
|
||||||
|
|
||||||
### Frontend Build
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn frontend:build
|
# From monorepo root — installs all workspaces including mobile
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mobile Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From monorepo root
|
|
||||||
yarn install
|
yarn install
|
||||||
|
|
||||||
# Start Expo dev server
|
# Start Expo dev server
|
||||||
yarn mobile:start
|
yarn workspace @cloud-reader/mobile start
|
||||||
|
|
||||||
# Run on Android
|
# Or cd into mobile and run directly
|
||||||
yarn mobile:android
|
cd mobile
|
||||||
|
npx expo start
|
||||||
# Run on iOS (macOS only)
|
|
||||||
yarn mobile:ios
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> The mobile API client defaults to `http://localhost:8000/`. For physical devices, update the `API_BASE` in `mobile/src/services/api.ts` to your machine's local IP.
|
> 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.
|
||||||
## Shared Package
|
- `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.
|
||||||
The `shared/` package contains TypeScript types and constants used by both the frontend and mobile apps.
|
- `api/` and `web/` directories removed.
|
||||||
|
- `mobile/` added as Expo React Native app with shared `@cloud-reader/shared` package.
|
||||||
```bash
|
|
||||||
# Build shared package
|
|
||||||
yarn shared:build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI/CD
|
|
||||||
|
|
||||||
Three independent CI pipelines run on pushes and PRs:
|
|
||||||
|
|
||||||
| Pipeline | Trigger Path | What It Does |
|
|
||||||
|----------|-------------|--------------|
|
|
||||||
| **Backend CI** | `backend/**` | Installs Python deps, runs Ruff linter, applies migrations, runs pytest |
|
|
||||||
| **Frontend CI** | `frontend/**`, `shared/**` | Installs Node deps, type-check & build shared, type-check & build frontend |
|
|
||||||
| **Mobile CI** | `mobile/**`, `shared/**` | Installs Node deps, type-check shared & mobile |
|
|
||||||
|
|
||||||
Pipeline configs are in `.github/workflows/`.
|
|
||||||
|
|
||||||
### Docker Deployments
|
|
||||||
|
|
||||||
Each app has its own Dockerfile for independent deployment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build backend image
|
|
||||||
docker build -t cloud-reader-backend backend/
|
|
||||||
|
|
||||||
# Build frontend image
|
|
||||||
docker build -t cloud-reader-frontend frontend/
|
|
||||||
|
|
||||||
# Build mobile image (web export)
|
|
||||||
docker build -t cloud-reader-mobile mobile/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Endpoint | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `POST /api/v1/auth/register/` | Create a new account |
|
|
||||||
| `POST /api/v1/auth/token/` | Obtain JWT tokens |
|
|
||||||
| `POST /api/v1/auth/token/refresh/` | Refresh JWT token |
|
|
||||||
| `GET /api/v1/auth/me/` | Get current user profile |
|
|
||||||
| `GET/POST /api/v1/documents/` | List / upload documents |
|
|
||||||
| `GET/PUT/DELETE /api/v1/documents/:id/` | Document detail |
|
|
||||||
| `GET/POST /api/v1/collections/` | List / create collections |
|
|
||||||
| `GET/PUT/DELETE /api/v1/collections/:id/` | Collection detail |
|
|
||||||
| `POST /api/v1/collections/:id/add_documents/` | Add docs to collection |
|
|
||||||
| `POST /api/v1/collections/:id/remove_documents/` | Remove docs from collection |
|
|
||||||
| `GET/POST /api/v1/reading/bookmarks/` | List / create bookmarks |
|
|
||||||
| `GET/POST /api/v1/reading/highlights/` | List / create highlights |
|
|
||||||
| `GET/POST /api/v1/reading/progress/` | Track reading progress |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Backend:** Django 5, Django REST Framework, SimpleJWT, PostgreSQL, drf-spectacular
|
|
||||||
- **Frontend:** React 18, TypeScript, Vite, React Router, Axios
|
|
||||||
- **Mobile:** Expo SDK 51, React Native 0.74, Expo Router
|
|
||||||
- **Shared:** TypeScript types, Zod schemas
|
|
||||||
- **CI/CD:** GitHub Actions
|
|
||||||
- **Container:** Docker (separate images per app)
|
|
||||||
+12
-11
@@ -1,15 +1,16 @@
|
|||||||
# Cloud Reader Backend — Environment Variables
|
# Backend environment (example – never commit real secrets)
|
||||||
# Copy to .env and fill in your values.
|
DJANGO_SECRET_KEY=django-insecure-change-me-in-production
|
||||||
|
DJANGO_DEBUG=True
|
||||||
SECRET_KEY=django-insecure-change-me-in-production
|
|
||||||
DEBUG=True
|
|
||||||
|
|
||||||
DB_ENGINE=django.db.backends.postgresql
|
|
||||||
DB_NAME=cloud_reader
|
DB_NAME=cloud_reader
|
||||||
DB_USER=cloud_reader
|
DB_USER=postgres
|
||||||
DB_PASSWORD=cloud_reader
|
DB_PASSWORD=postgres
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
# Open Library metadata enrichment (optional)
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
OPENLIBRARY_ENABLED=true
|
||||||
|
OPENLIBRARY_PREFERRED_LANG=es
|
||||||
|
OPENLIBRARY_FALLBACK_LANG=en
|
||||||
|
OPENLIBRARY_TIMEOUT_SECONDS=10
|
||||||
|
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS=15
|
||||||
|
OPENLIBRARY_USER_AGENT=CloudReader/1.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
+9
-9
@@ -1,18 +1,18 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
DJANGO_SETTINGS_MODULE=config.settings
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements/production.txt /app/requirements/
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN pip install --no-cache-dir -r requirements/production.txt
|
libpq-dev gcc && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY . /app
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
RUN python manage.py collectstatic --noinput
|
COPY . ./
|
||||||
|
|
||||||
|
RUN mkdir -p media
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from .models import User
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
|
||||||
class UserAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["email", "display_name", "is_verified", "is_active", "date_joined"]
|
|
||||||
search_fields = ["email", "display_name"]
|
|
||||||
list_filter = ["is_verified", "is_active"]
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
from django.contrib.auth.models import AbstractUser
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
|
||||||
"""Custom user model for Cloud Reader."""
|
|
||||||
|
|
||||||
email = models.EmailField(unique=True)
|
|
||||||
display_name = models.CharField(max_length=150, blank=True)
|
|
||||||
avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
|
|
||||||
is_verified = models.BooleanField(default=False)
|
|
||||||
reading_preferences = models.JSONField(default=dict, blank=True)
|
|
||||||
|
|
||||||
USERNAME_FIELD = "email"
|
|
||||||
REQUIRED_FIELDS = ["username"]
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "accounts_user"
|
|
||||||
verbose_name = "User"
|
|
||||||
verbose_name_plural = "Users"
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.email
|
|
||||||
|
|
||||||
@property
|
|
||||||
def avatar_url(self) -> str | None:
|
|
||||||
if self.avatar:
|
|
||||||
return self.avatar.url
|
|
||||||
return None
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
from django.contrib.auth import get_user_model
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from .models import User
|
|
||||||
|
|
||||||
UserModel = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterSerializer(serializers.ModelSerializer[User]):
|
|
||||||
password = serializers.CharField(write_only=True, min_length=8)
|
|
||||||
password_confirm = serializers.CharField(write_only=True, min_length=8)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ["email", "username", "display_name", "password", "password_confirm"]
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
if attrs["password"] != attrs.pop("password_confirm"):
|
|
||||||
raise serializers.ValidationError({"password_confirm": "Passwords do not match."})
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
|
||||||
password = validated_data.pop("password")
|
|
||||||
user = UserModel(**validated_data)
|
|
||||||
user.set_password(password)
|
|
||||||
user.save()
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer[User]):
|
|
||||||
avatar_url = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
"id", "email", "username", "display_name", "avatar_url",
|
|
||||||
"date_joined", "is_verified", "reading_preferences",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "email", "date_joined", "is_verified"]
|
|
||||||
|
|
||||||
def get_avatar_url(self, obj: User) -> str | None:
|
|
||||||
return obj.avatar_url
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordSerializer(serializers.Serializer):
|
|
||||||
old_password = serializers.CharField(required=True)
|
|
||||||
new_password = serializers.CharField(required=True, min_length=8)
|
|
||||||
|
|
||||||
def validate_old_password(self, value: str) -> str:
|
|
||||||
user = self.context["request"].user
|
|
||||||
if not user.check_password(value):
|
|
||||||
raise serializers.ValidationError("Current password is incorrect.")
|
|
||||||
return value
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
app_name = "accounts"
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("register/", views.RegisterView.as_view(), name="register"),
|
|
||||||
path("me/", views.UserDetailView.as_view(), name="user-detail"),
|
|
||||||
path("change-password/", views.ChangePasswordView.as_view(), name="change-password"),
|
|
||||||
path("token/", TokenObtainPairView.as_view(), name="token-obtain"),
|
|
||||||
path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"),
|
|
||||||
]
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
from django.contrib.auth import get_user_model
|
|
||||||
from rest_framework import generics, permissions, status
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
|
|
||||||
from .serializers import ChangePasswordSerializer, RegisterSerializer, UserSerializer
|
|
||||||
|
|
||||||
UserModel = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterView(generics.CreateAPIView):
|
|
||||||
"""Create a new user account."""
|
|
||||||
queryset = UserModel.objects.all()
|
|
||||||
serializer_class = RegisterSerializer
|
|
||||||
permission_classes = [permissions.AllowAny]
|
|
||||||
|
|
||||||
|
|
||||||
class UserDetailView(generics.RetrieveUpdateAPIView):
|
|
||||||
"""Get or update the authenticated user's profile."""
|
|
||||||
serializer_class = UserSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_object(self):
|
|
||||||
return self.request.user
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordView(APIView):
|
|
||||||
"""Change the authenticated user's password."""
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
request.user.set_password(serializer.validated_data["new_password"])
|
|
||||||
request.user.save()
|
|
||||||
return Response({"detail": "Password changed successfully."}, status=status.HTTP_200_OK)
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.annotations.models import Bookmark, Note
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Bookmark)
|
||||||
|
class BookmarkAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"user",
|
||||||
|
"ebook",
|
||||||
|
"chapter_index",
|
||||||
|
"chapter_title",
|
||||||
|
"page",
|
||||||
|
"highlight_color",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
list_select_related = ("user", "ebook")
|
||||||
|
search_fields = ("user__email", "ebook__title", "location_text", "content")
|
||||||
|
list_filter = ("created_at",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Note)
|
||||||
|
class NoteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "book", "page", "created_at", "updated_at")
|
||||||
|
list_select_related = ("user", "book")
|
||||||
|
search_fields = ("user__email", "book__title", "content", "location_text")
|
||||||
|
list_filter = ("created_at",)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AnnotationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.annotations"
|
||||||
|
label = "annotations"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-06-03 22:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('books', '0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Note',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('page', models.PositiveIntegerField()),
|
||||||
|
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text this note refers to')),
|
||||||
|
('content', models.TextField(help_text='The note body content')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='books.book')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Note',
|
||||||
|
'verbose_name_plural': 'Notes',
|
||||||
|
'db_table': 'annotations_note',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Bookmark',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('page', models.PositiveIntegerField()),
|
||||||
|
('location_text', models.TextField(blank=True, default='', help_text='The selected passage text at this location')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.book')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Bookmark',
|
||||||
|
'verbose_name_plural': 'Bookmarks',
|
||||||
|
'db_table': 'annotations_bookmark',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('user', 'book', 'page'), name='uq_bookmark_user_book_page')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
def clear_legacy_bookmarks(apps, schema_editor):
|
||||||
|
Bookmark = apps.get_model("annotations", "Bookmark")
|
||||||
|
Bookmark.objects.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0003_readingprogress_epub_location"),
|
||||||
|
("annotations", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(clear_legacy_bookmarks, migrations.RunPython.noop),
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="uq_bookmark_user_book_page",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="book",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="ebook",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="bookmarks",
|
||||||
|
to="books.ebook",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="epub_cfi",
|
||||||
|
field=models.CharField(db_index=True, default="", max_length=2048),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="chapter_index",
|
||||||
|
field=models.PositiveIntegerField(db_index=True, default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="chapter_title",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=512),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="content",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="page",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="Legacy/display page; derived from chapter_index + 1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="bookmark",
|
||||||
|
options={
|
||||||
|
"ordering": ["chapter_index", "epub_cfi"],
|
||||||
|
"verbose_name": "Bookmark",
|
||||||
|
"verbose_name_plural": "Bookmarks",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
# ebook was added nullable for SQLite; enforce NOT NULL via AlterField
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="ebook",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="bookmarks",
|
||||||
|
to="books.ebook",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="bookmark",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user", "ebook", "epub_cfi"),
|
||||||
|
name="uq_bookmark_user_ebook_cfi",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("annotations", "0002_bookmark_ebook_epub_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="bookmark",
|
||||||
|
name="highlight_color",
|
||||||
|
field=models.CharField(default="#fde047", max_length=7),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-06-04 03:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('annotations', '0003_bookmark_highlight_color'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='bookmark',
|
||||||
|
name='content',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Optional user thought; empty means bookmark-only'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='bookmark',
|
||||||
|
name='highlight_color',
|
||||||
|
field=models.CharField(default='#fde047', help_text='Hex color for in-book passage highlight (e.g. #fde047)', max_length=7),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Bookmark(models.Model):
|
||||||
|
"""A saved passage anchor in an uploaded ebook (EPUB CFI + optional thought)."""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="bookmarks",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
ebook = models.ForeignKey(
|
||||||
|
"books.EBook",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="bookmarks",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
epub_cfi = models.CharField(max_length=2048, db_index=True)
|
||||||
|
chapter_index = models.PositiveIntegerField(default=0, db_index=True)
|
||||||
|
chapter_title = models.CharField(max_length=512, blank=True, default="")
|
||||||
|
page = models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="Legacy/display page; derived from chapter_index + 1",
|
||||||
|
)
|
||||||
|
location_text = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="The selected passage text at this location",
|
||||||
|
)
|
||||||
|
content = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Optional user thought; empty means bookmark-only",
|
||||||
|
)
|
||||||
|
highlight_color = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
default="#fde047",
|
||||||
|
help_text="Hex color for in-book passage highlight (e.g. #fde047)",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "annotations_bookmark"
|
||||||
|
verbose_name = "Bookmark"
|
||||||
|
verbose_name_plural = "Bookmarks"
|
||||||
|
ordering = ["chapter_index", "epub_cfi"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "ebook", "epub_cfi"],
|
||||||
|
name="uq_bookmark_user_ebook_cfi",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.user} @ {self.ebook} ch.{self.chapter_index}"
|
||||||
|
|
||||||
|
|
||||||
|
class Note(models.Model):
|
||||||
|
"""Legacy note model; new UX uses Bookmark.content instead."""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notes",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
book = models.ForeignKey(
|
||||||
|
"books.Book",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notes",
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
page = models.PositiveIntegerField()
|
||||||
|
location_text = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="The selected passage text this note refers to",
|
||||||
|
)
|
||||||
|
content = models.TextField(
|
||||||
|
help_text="The note body content"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "annotations_note"
|
||||||
|
verbose_name = "Note"
|
||||||
|
verbose_name_plural = "Notes"
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
preview = self.content[:50]
|
||||||
|
return f"{self.user} @ {self.book} p.{self.page}: {preview}"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
|
||||||
|
class IsOwner(permissions.BasePermission):
|
||||||
|
"""Grant access only if the requesting user owns the object."""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj) -> bool:
|
||||||
|
return obj.user == request.user
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.annotations.models import Bookmark, Note
|
||||||
|
|
||||||
|
_HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_highlight_color(value: str) -> str:
|
||||||
|
stripped = (value or "").strip()
|
||||||
|
if not _HEX_COLOR_RE.match(stripped):
|
||||||
|
raise serializers.ValidationError("highlight_color must be a hex color like #fde047.")
|
||||||
|
return stripped.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkSerializer(serializers.ModelSerializer):
|
||||||
|
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"ebook",
|
||||||
|
"ebook_title",
|
||||||
|
"epub_cfi",
|
||||||
|
"chapter_index",
|
||||||
|
"chapter_title",
|
||||||
|
"page",
|
||||||
|
"location_text",
|
||||||
|
"content",
|
||||||
|
"highlight_color",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "created_at", "updated_at", "ebook_title", "page"]
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Bookmark
|
||||||
|
fields = [
|
||||||
|
"ebook",
|
||||||
|
"epub_cfi",
|
||||||
|
"chapter_index",
|
||||||
|
"chapter_title",
|
||||||
|
"location_text",
|
||||||
|
"content",
|
||||||
|
"highlight_color",
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate_highlight_color(self, value: str) -> str:
|
||||||
|
return validate_highlight_color(value)
|
||||||
|
|
||||||
|
def validate_epub_cfi(self, value: str) -> str:
|
||||||
|
stripped = (value or "").strip()
|
||||||
|
if not stripped:
|
||||||
|
raise serializers.ValidationError("epub_cfi is required.")
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
def validate_chapter_index(self, value: int) -> int:
|
||||||
|
if value < 0:
|
||||||
|
raise serializers.ValidationError("chapter_index must be non-negative.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
user = self.context["request"].user
|
||||||
|
ebook = attrs["ebook"]
|
||||||
|
epub_cfi = attrs["epub_cfi"]
|
||||||
|
if Bookmark.objects.filter(user=user, ebook=ebook, epub_cfi=epub_cfi).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"epub_cfi": "A marker already exists for this passage."}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data["user"] = self.context["request"].user
|
||||||
|
validated_data["page"] = validated_data.get("chapter_index", 0) + 1
|
||||||
|
validated_data["content"] = (validated_data.get("content") or "").strip()
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class NoteSerializer(serializers.ModelSerializer):
|
||||||
|
book_title = serializers.CharField(source="book.title", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Note
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"book",
|
||||||
|
"book_title",
|
||||||
|
"page",
|
||||||
|
"location_text",
|
||||||
|
"content",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "created_at", "updated_at", "book_title"]
|
||||||
|
|
||||||
|
def validate_page(self, value: int) -> int:
|
||||||
|
if value < 1:
|
||||||
|
raise serializers.ValidationError("Page must be a positive integer.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class NoteCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Note
|
||||||
|
fields = ["book", "page", "location_text", "content"]
|
||||||
|
|
||||||
|
def validate_page(self, value: int) -> int:
|
||||||
|
if value < 1:
|
||||||
|
raise serializers.ValidationError("Page must be a positive integer.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_content(self, value: str) -> str:
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise serializers.ValidationError("Note content cannot be empty.")
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data["user"] = self.context["request"].user
|
||||||
|
return super().create(validated_data)
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Tests for the annotations app – Bookmarks & Notes API."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.annotations.models import Bookmark, Note
|
||||||
|
from apps.books.models import Book, EBook
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client() -> APIClient:
|
||||||
|
return APIClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user() -> User:
|
||||||
|
return User.objects.create_user(
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_user() -> User:
|
||||||
|
return User.objects.create_user(
|
||||||
|
username="other",
|
||||||
|
email="other@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(api_client: APIClient, user: User) -> APIClient:
|
||||||
|
api_client.force_authenticate(user=user)
|
||||||
|
return api_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def book() -> Book:
|
||||||
|
return Book.objects.create(
|
||||||
|
title="Test Book",
|
||||||
|
author="Test Author",
|
||||||
|
total_pages=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ebook(user: User) -> EBook:
|
||||||
|
return EBook.objects.create(
|
||||||
|
user=user,
|
||||||
|
title="Test Ebook",
|
||||||
|
author="Test Author",
|
||||||
|
format="epub",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bookmark(auth_client, user: User, ebook: EBook) -> Bookmark:
|
||||||
|
return Bookmark.objects.create(
|
||||||
|
user=user,
|
||||||
|
ebook=ebook,
|
||||||
|
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:10)",
|
||||||
|
chapter_index=2,
|
||||||
|
chapter_title="Chapter 3",
|
||||||
|
page=3,
|
||||||
|
location_text="important passage",
|
||||||
|
content="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def note(auth_client, user: User, book: Book) -> Note:
|
||||||
|
return Note.objects.create(
|
||||||
|
user=user,
|
||||||
|
book=book,
|
||||||
|
page=15,
|
||||||
|
location_text="highlighted section",
|
||||||
|
content="This is my note about this section.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookmarkList:
|
||||||
|
url = reverse("bookmark-list")
|
||||||
|
|
||||||
|
def test_list_requires_auth(self, api_client: APIClient):
|
||||||
|
response = api_client.get(self.url)
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
def test_list_returns_user_bookmarks_only(
|
||||||
|
self, auth_client: APIClient, user: User, other_user: User, ebook: EBook
|
||||||
|
):
|
||||||
|
Bookmark.objects.create(
|
||||||
|
user=user,
|
||||||
|
ebook=ebook,
|
||||||
|
epub_cfi="epubcfi(/6/4!/4/2,/1:0,/1:5)",
|
||||||
|
chapter_index=0,
|
||||||
|
page=1,
|
||||||
|
)
|
||||||
|
other_ebook = EBook.objects.create(user=other_user, title="Other", format="epub")
|
||||||
|
Bookmark.objects.create(
|
||||||
|
user=other_user,
|
||||||
|
ebook=other_ebook,
|
||||||
|
epub_cfi="epubcfi(/6/4!/4/2,/2:0,/2:5)",
|
||||||
|
chapter_index=0,
|
||||||
|
page=1,
|
||||||
|
)
|
||||||
|
response = auth_client.get(self.url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
results = response.data["results"]
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
def test_filter_by_ebook(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
||||||
|
response = auth_client.get(self.url, {"ebook": str(ebook.id)})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.data["results"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookmarkCreate:
|
||||||
|
url = reverse("bookmark-list")
|
||||||
|
|
||||||
|
def test_create_marker(self, auth_client: APIClient, ebook: EBook):
|
||||||
|
data = {
|
||||||
|
"ebook": ebook.id,
|
||||||
|
"epub_cfi": "epubcfi(/6/4!/4/2,/1:0,/1:20)",
|
||||||
|
"chapter_index": 1,
|
||||||
|
"chapter_title": "Chapter 2",
|
||||||
|
"location_text": "Selected text",
|
||||||
|
"content": "My thought",
|
||||||
|
}
|
||||||
|
response = auth_client.post(self.url, data, format="json")
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert response.data["content"] == "My thought"
|
||||||
|
assert response.data["ebook"] == ebook.id
|
||||||
|
|
||||||
|
def test_create_bookmark_only_empty_content(self, auth_client: APIClient, ebook: EBook):
|
||||||
|
data = {
|
||||||
|
"ebook": ebook.id,
|
||||||
|
"epub_cfi": "epubcfi(/6/4!/4/2,/3:0,/3:8)",
|
||||||
|
"chapter_index": 0,
|
||||||
|
"location_text": "Quote only",
|
||||||
|
"content": "",
|
||||||
|
}
|
||||||
|
response = auth_client.post(self.url, data, format="json")
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert response.data["content"] == ""
|
||||||
|
|
||||||
|
def test_duplicate_cfi_rejected(self, auth_client: APIClient, bookmark: Bookmark, ebook: EBook):
|
||||||
|
data = {
|
||||||
|
"ebook": ebook.id,
|
||||||
|
"epub_cfi": bookmark.epub_cfi,
|
||||||
|
"chapter_index": 0,
|
||||||
|
"location_text": "dup",
|
||||||
|
}
|
||||||
|
response = auth_client.post(self.url, data, format="json")
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookmarkDetail:
|
||||||
|
def test_delete_bookmark(self, auth_client: APIClient, bookmark: Bookmark):
|
||||||
|
url = reverse("bookmark-detail", args=[str(bookmark.id)])
|
||||||
|
response = auth_client.delete(url)
|
||||||
|
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.annotations.views import BookmarkViewSet, NoteViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
|
||||||
|
router.register(r"notes", NoteViewSet, basename="note")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.annotations.models import Bookmark, Note
|
||||||
|
from apps.annotations.permissions import IsOwner
|
||||||
|
from apps.annotations.serializers import (
|
||||||
|
BookmarkCreateSerializer,
|
||||||
|
BookmarkSerializer,
|
||||||
|
NoteCreateSerializer,
|
||||||
|
NoteSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkViewSet(viewsets.ModelViewSet):
|
||||||
|
"""CRUD for user ebook markers (passage anchors + optional thoughts)."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated, IsOwner]
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
|
filterset_fields = ["ebook"]
|
||||||
|
search_fields = ["location_text", "content", "chapter_title"]
|
||||||
|
ordering_fields = ["chapter_index", "epub_cfi", "created_at"]
|
||||||
|
ordering = ["chapter_index", "epub_cfi"]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return BookmarkCreateSerializer
|
||||||
|
return BookmarkSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Bookmark.objects.filter(user=self.request.user).select_related("ebook")
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["delete"], url_path="batch-delete")
|
||||||
|
def batch_delete(self, request):
|
||||||
|
ids = request.data.get("ids", [])
|
||||||
|
if not ids:
|
||||||
|
return Response(
|
||||||
|
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
deleted, _ = Bookmark.objects.filter(
|
||||||
|
id__in=ids, user=request.user
|
||||||
|
).delete()
|
||||||
|
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class NoteViewSet(viewsets.ModelViewSet):
|
||||||
|
"""Legacy notes API (catalog Book FK)."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated, IsOwner]
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
|
filterset_fields = ["book"]
|
||||||
|
search_fields = ["content", "location_text"]
|
||||||
|
ordering_fields = ["created_at", "page"]
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return NoteCreateSerializer
|
||||||
|
return NoteSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Note.objects.filter(user=self.request.user).select_related("book")
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["delete"], url_path="batch-delete")
|
||||||
|
def batch_delete(self, request):
|
||||||
|
ids = request.data.get("ids", [])
|
||||||
|
if not ids:
|
||||||
|
return Response(
|
||||||
|
{"detail": "No ids provided."}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
deleted, _ = Note.objects.filter(
|
||||||
|
id__in=ids, user=request.user
|
||||||
|
).delete()
|
||||||
|
return Response({"deleted": deleted}, status=status.HTTP_200_OK)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.books.models import Book
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Book)
|
||||||
|
class BookAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("title", "author", "total_pages", "created_at")
|
||||||
|
search_fields = ("title", "author")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BooksConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.books"
|
||||||
|
label = "books"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-05-26 03:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Book',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(db_index=True, max_length=512)),
|
||||||
|
('author', models.CharField(blank=True, db_index=True, default='', max_length=256)),
|
||||||
|
('genre', models.CharField(blank=True, db_index=True, default='', max_length=128)),
|
||||||
|
('description', models.TextField(blank=True, default='')),
|
||||||
|
('reading_status', models.CharField(choices=[('want_to_read', 'Want to Read'), ('reading', 'Reading'), ('finished', 'Finished'), ('dnf', 'Did Not Finish')], db_index=True, default='want_to_read', max_length=20)),
|
||||||
|
('total_pages', models.PositiveIntegerField(default=0)),
|
||||||
|
('cover_image', models.URLField(blank=True, default='')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Book',
|
||||||
|
'verbose_name_plural': 'Books',
|
||||||
|
'db_table': 'books_book',
|
||||||
|
'ordering': ['title'],
|
||||||
|
'indexes': [models.Index(fields=['title', 'author', 'genre'], name='books_book_title_9fddc2_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EBook',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=512)),
|
||||||
|
('author', models.CharField(blank=True, default='', max_length=256)),
|
||||||
|
('file', models.FileField(upload_to='ebooks/%Y/%m/%d/')),
|
||||||
|
('cover_image', models.ImageField(blank=True, null=True, upload_to='ebook_covers/%Y/%m/%d/')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebooks', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'E-Book',
|
||||||
|
'verbose_name_plural': 'E-Books',
|
||||||
|
'db_table': 'books_ebook',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReadingProgress',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('current_position', models.FloatField(default=0.0)),
|
||||||
|
('last_page', models.IntegerField(default=0)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('ebook', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to='books.ebook')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reading_progress', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'reading progress',
|
||||||
|
'db_table': 'books_reading_progress',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReadingSettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('font_size', models.IntegerField(default=18)),
|
||||||
|
('font_style', models.CharField(choices=[('sans-serif', 'Sans Serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='sans-serif', max_length=20)),
|
||||||
|
('background_color', models.CharField(choices=[('#ffffff', 'White'), ('#f4e4c1', 'Sepia'), ('#1a1a2e', 'Dark'), ('#c7edcc', 'Green')], default='#ffffff', max_length=7)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reading_settings', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'reading settings',
|
||||||
|
'db_table': 'books_reading_settings',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='ebook',
|
||||||
|
index=models.Index(fields=['user', '-created_at'], name='books_ebook_user_id_0b6bdb_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='readingprogress',
|
||||||
|
unique_together={('user', 'ebook')},
|
||||||
|
),
|
||||||
|
]
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-06-03 22:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('books', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='file_size',
|
||||||
|
field=models.BigIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='format',
|
||||||
|
field=models.CharField(blank=True, default='', editable=False, max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='metadata_json',
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='page_count',
|
||||||
|
field=models.PositiveIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='readingprogress',
|
||||||
|
name='device_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='readingprogress',
|
||||||
|
name='device_name',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='readingprogress',
|
||||||
|
name='version',
|
||||||
|
field=models.PositiveIntegerField(default=1),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='author',
|
||||||
|
field=models.CharField(blank=True, db_index=True, default='', max_length=256),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ebook',
|
||||||
|
name='title',
|
||||||
|
field=models.CharField(db_index=True, max_length=512),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BookChapter',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=512)),
|
||||||
|
('index', models.IntegerField(default=0)),
|
||||||
|
('href', models.CharField(blank=True, default='', max_length=1024)),
|
||||||
|
('children', models.JSONField(blank=True, default=list)),
|
||||||
|
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='books.ebook')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Book Chapter',
|
||||||
|
'verbose_name_plural': 'Book Chapters',
|
||||||
|
'db_table': 'books_book_chapter',
|
||||||
|
'ordering': ['index'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DownloadRecord',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file_size', models.BigIntegerField(default=0)),
|
||||||
|
('downloaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('ebook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to='books.ebook')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='download_records', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Download Record',
|
||||||
|
'verbose_name_plural': 'Download Records',
|
||||||
|
'db_table': 'books_download_record',
|
||||||
|
'ordering': ['-downloaded_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='ReadingSettings',
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='bookchapter',
|
||||||
|
index=models.Index(fields=['ebook', 'index'], name='books_book__ebook_i_464cd6_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='downloadrecord',
|
||||||
|
unique_together={('user', 'ebook')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("books", "0002_ebook_file_size_ebook_format_ebook_metadata_json_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="readingprogress",
|
||||||
|
name="epub_location",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=2048),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingStatus(models.TextChoices):
|
||||||
|
WANT_TO_READ = "want_to_read", "Want to Read"
|
||||||
|
READING = "reading", "Reading"
|
||||||
|
FINISHED = "finished", "Finished"
|
||||||
|
DNF = "dnf", "Did Not Finish"
|
||||||
|
|
||||||
|
|
||||||
|
class Book(models.Model):
|
||||||
|
title = models.CharField(max_length=512, db_index=True)
|
||||||
|
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
|
||||||
|
genre = models.CharField(max_length=128, blank=True, default="", db_index=True)
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
reading_status = models.CharField(max_length=20, choices=ReadingStatus.choices, default=ReadingStatus.WANT_TO_READ, db_index=True)
|
||||||
|
total_pages = models.PositiveIntegerField(default=0)
|
||||||
|
cover_image = models.URLField(blank=True, default="")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_book"
|
||||||
|
verbose_name = "Book"
|
||||||
|
verbose_name_plural = "Books"
|
||||||
|
ordering = ["title"]
|
||||||
|
indexes = [models.Index(fields=["title", "author", "genre"])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class EBook(models.Model):
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ebooks")
|
||||||
|
title = models.CharField(max_length=512, db_index=True)
|
||||||
|
author = models.CharField(max_length=256, blank=True, default="", db_index=True)
|
||||||
|
format = models.CharField(max_length=20, blank=True, default="", editable=False)
|
||||||
|
page_count = models.PositiveIntegerField(default=0)
|
||||||
|
file_size = models.BigIntegerField(default=0)
|
||||||
|
metadata_json = models.JSONField(blank=True, default=dict)
|
||||||
|
file = models.FileField(upload_to="ebooks/%Y/%m/%d/")
|
||||||
|
cover_image = models.ImageField(upload_to="ebook_covers/%Y/%m/%d/", blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_ebook"
|
||||||
|
verbose_name = "E-Book"
|
||||||
|
verbose_name_plural = "E-Books"
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [models.Index(fields=["user", "-created_at"])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def filename(self):
|
||||||
|
return Path(self.file.name).name if self.file else ""
|
||||||
|
|
||||||
|
|
||||||
|
class BookChapter(models.Model):
|
||||||
|
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="chapters")
|
||||||
|
title = models.CharField(max_length=512)
|
||||||
|
index = models.IntegerField(default=0)
|
||||||
|
href = models.CharField(max_length=1024, blank=True, default="")
|
||||||
|
children = models.JSONField(blank=True, default=list)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_book_chapter"
|
||||||
|
verbose_name = "Book Chapter"
|
||||||
|
verbose_name_plural = "Book Chapters"
|
||||||
|
ordering = ["index"]
|
||||||
|
indexes = [models.Index(fields=["ebook", "index"])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.ebook.title} - {self.title}"
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=EBook)
|
||||||
|
def _auto_delete_ebook_file(sender, instance, **kwargs):
|
||||||
|
if instance.file:
|
||||||
|
instance.file.delete(save=False)
|
||||||
|
if instance.cover_image:
|
||||||
|
instance.cover_image.delete(save=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadRecord(models.Model):
|
||||||
|
"""Tracks book downloads for offline access management."""
|
||||||
|
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="download_records")
|
||||||
|
ebook = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name="download_records")
|
||||||
|
file_size = models.BigIntegerField(default=0)
|
||||||
|
downloaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_download_record"
|
||||||
|
verbose_name = "Download Record"
|
||||||
|
verbose_name_plural = "Download Records"
|
||||||
|
ordering = ["-downloaded_at"]
|
||||||
|
unique_together = [("user", "ebook")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} - {self.ebook.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingProgress(models.Model):
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reading_progress")
|
||||||
|
ebook = models.OneToOneField(EBook, on_delete=models.CASCADE, related_name="reading_progress")
|
||||||
|
current_position = models.FloatField(default=0.0)
|
||||||
|
last_page = models.IntegerField(default=0)
|
||||||
|
epub_location = models.CharField(max_length=2048, blank=True, default="")
|
||||||
|
device_id = models.CharField(max_length=128, blank=True, default="")
|
||||||
|
device_name = models.CharField(max_length=128, blank=True, default="")
|
||||||
|
version = models.PositiveIntegerField(default=1)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "books_reading_progress"
|
||||||
|
verbose_name_plural = "reading progress"
|
||||||
|
unique_together = [("user", "ebook")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.ebook.title} - {self.current_position:.1f}%"
|
||||||
|
|
||||||
|
def update_with_sync(self, position: float, last_page: int,
|
||||||
|
device_id: str, device_name: str,
|
||||||
|
client_updated_at: str | None = None) -> tuple["ReadingProgress", bool]:
|
||||||
|
"""Update progress with conflict resolution (last-write-wins by timestamp).
|
||||||
|
|
||||||
|
Returns (instance, applied) where applied is True if the update was applied.
|
||||||
|
"""
|
||||||
|
if client_updated_at and self.updated_at:
|
||||||
|
try:
|
||||||
|
from django.utils.timezone import is_naive, make_aware
|
||||||
|
from datetime import datetime
|
||||||
|
client_dt = datetime.fromisoformat(client_updated_at.replace("Z", "+00:00"))
|
||||||
|
if is_naive(client_dt):
|
||||||
|
client_dt = make_aware(client_dt)
|
||||||
|
if client_dt <= self.updated_at:
|
||||||
|
return self, False
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.current_position = position
|
||||||
|
self.last_page = last_page
|
||||||
|
self.device_id = device_id
|
||||||
|
self.device_name = device_name
|
||||||
|
self.version += 1
|
||||||
|
self.save(update_fields=[
|
||||||
|
"current_position", "last_page",
|
||||||
|
"device_id", "device_name", "version", "updated_at",
|
||||||
|
])
|
||||||
|
return self, True
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord
|
||||||
|
from apps.books.services.ebook_metadata import subjects_from_ebook
|
||||||
|
from apps.books.services.metadata import enrich_ebook_metadata
|
||||||
|
from apps.books.services.process_ebook import apply_processing_to_ebook
|
||||||
|
from apps.reader.models import ReadingSettings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class BookReadingSettingsSerializer(serializers.ModelSerializer):
|
||||||
|
font_style = serializers.CharField(source="font_family")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReadingSettings
|
||||||
|
fields = ["font_size", "font_style", "background_color"]
|
||||||
|
|
||||||
|
def validate_font_size(self, value: int) -> int:
|
||||||
|
if value < 12 or value > 36:
|
||||||
|
raise serializers.ValidationError("Font size must be between 12 and 36.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class BookChapterSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = BookChapter
|
||||||
|
fields = ["id", "title", "index", "href", "children"]
|
||||||
|
|
||||||
|
|
||||||
|
class EBookContentSerializer(serializers.Serializer):
|
||||||
|
page = serializers.IntegerField()
|
||||||
|
total_pages = serializers.IntegerField()
|
||||||
|
content = serializers.CharField()
|
||||||
|
chapter_title = serializers.CharField()
|
||||||
|
format = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class EBookTocSerializer(serializers.Serializer):
|
||||||
|
chapters = serializers.ListField(child=BookChapterSerializer())
|
||||||
|
format = serializers.CharField()
|
||||||
|
page_count = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class BookListSerializer(serializers.ModelSerializer):
|
||||||
|
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = ["id", "title", "author", "genre", "reading_status", "reading_status_display", "cover_image"]
|
||||||
|
|
||||||
|
|
||||||
|
class BookDetailSerializer(serializers.ModelSerializer):
|
||||||
|
reading_status_display = serializers.CharField(source="get_reading_status_display", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = ["id", "title", "author", "genre", "description", "reading_status", "reading_status_display", "cover_image", "total_pages", "created_at", "updated_at"]
|
||||||
|
read_only_fields = ["id", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class BookSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = ["id", "title", "author", "genre", "description", "reading_status", "cover_image", "total_pages", "created_at", "updated_at"]
|
||||||
|
read_only_fields = ["id", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class EBookListSerializer(serializers.ModelSerializer):
|
||||||
|
filename = serializers.CharField(read_only=True)
|
||||||
|
format = serializers.CharField(read_only=True)
|
||||||
|
progress = serializers.SerializerMethodField()
|
||||||
|
started = serializers.SerializerMethodField()
|
||||||
|
subjects = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = EBook
|
||||||
|
fields = [
|
||||||
|
"id", "title", "author", "filename", "format", "page_count", "file_size",
|
||||||
|
"cover_image", "created_at", "progress", "started", "subjects",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_subjects(self, obj: EBook) -> list[str]:
|
||||||
|
return subjects_from_ebook(obj)
|
||||||
|
|
||||||
|
def get_progress(self, obj):
|
||||||
|
try:
|
||||||
|
return obj.reading_progress.current_position
|
||||||
|
except ReadingProgress.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_started(self, obj):
|
||||||
|
"""True when the user has opened the reader with saved progress."""
|
||||||
|
try:
|
||||||
|
rp = obj.reading_progress
|
||||||
|
except ReadingProgress.DoesNotExist:
|
||||||
|
return False
|
||||||
|
if rp.current_position >= 99:
|
||||||
|
return False
|
||||||
|
if obj.format == "pdf":
|
||||||
|
return rp.last_page > 0 or rp.current_position > 0
|
||||||
|
return bool((rp.epub_location or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
class EBookDetailSerializer(serializers.ModelSerializer):
|
||||||
|
filename = serializers.CharField(read_only=True)
|
||||||
|
format = serializers.CharField(read_only=True)
|
||||||
|
file_url = serializers.SerializerMethodField()
|
||||||
|
progress = serializers.SerializerMethodField()
|
||||||
|
metadata = serializers.JSONField(source="metadata_json", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = EBook
|
||||||
|
fields = [
|
||||||
|
"id", "title", "author", "filename", "format", "page_count", "file_size",
|
||||||
|
"file_url", "cover_image", "metadata", "created_at", "updated_at", "progress",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_file_url(self, obj):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and obj.file:
|
||||||
|
return request.build_absolute_uri(obj.file.url)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_progress(self, obj):
|
||||||
|
try:
|
||||||
|
rp = obj.reading_progress
|
||||||
|
return {
|
||||||
|
"current_position": rp.current_position,
|
||||||
|
"last_page": rp.last_page,
|
||||||
|
"epub_location": rp.epub_location,
|
||||||
|
}
|
||||||
|
except ReadingProgress.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class EBookUploadSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = EBook
|
||||||
|
fields = ["title", "author", "file", "cover_image"]
|
||||||
|
extra_kwargs = {"title": {"required": True}, "file": {"required": True}}
|
||||||
|
|
||||||
|
def validate_file(self, value):
|
||||||
|
import os
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
ext = os.path.splitext(str(getattr(value, "name", "")))[1].lower()
|
||||||
|
if ext not in (".epub", ".pdf"):
|
||||||
|
raise serializers.ValidationError("Only EPUB and PDF files are supported.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
import os
|
||||||
|
|
||||||
|
validated_data["user"] = self.context["request"].user
|
||||||
|
name = str(getattr(validated_data.get("file"), "name", ""))
|
||||||
|
ext = os.path.splitext(name)[1].lower().lstrip(".")
|
||||||
|
if ext:
|
||||||
|
validated_data["format"] = ext
|
||||||
|
ebook = super().create(validated_data)
|
||||||
|
try:
|
||||||
|
apply_processing_to_ebook(ebook)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("E-book processing failed for ebook %s", ebook.pk)
|
||||||
|
try:
|
||||||
|
enrich_ebook_metadata(ebook)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Metadata enrichment failed for ebook %s", ebook.pk)
|
||||||
|
ebook.refresh_from_db()
|
||||||
|
return ebook
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingProgressSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ReadingProgress
|
||||||
|
fields = [
|
||||||
|
"current_position", "last_page", "epub_location",
|
||||||
|
"device_id", "device_name", "version", "updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = ["version", "updated_at"]
|
||||||
|
extra_kwargs = {"current_position": {"required": False, "min_value": 0.0, "max_value": 100.0}}
|
||||||
|
|
||||||
|
def validate_current_position(self, value):
|
||||||
|
if value < 0.0 or value > 100.0:
|
||||||
|
raise serializers.ValidationError("Position must be between 0.0 and 100.0.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadRecordSerializer(serializers.ModelSerializer):
|
||||||
|
ebook_id = serializers.IntegerField(source="ebook.id", read_only=True)
|
||||||
|
ebook_title = serializers.CharField(source="ebook.title", read_only=True)
|
||||||
|
author = serializers.CharField(source="ebook.author", read_only=True)
|
||||||
|
filename = serializers.SerializerMethodField()
|
||||||
|
cover_image = serializers.ImageField(source="ebook.cover_image", read_only=True)
|
||||||
|
format = serializers.CharField(source="ebook.format", read_only=True)
|
||||||
|
progress = serializers.SerializerMethodField()
|
||||||
|
file_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DownloadRecord
|
||||||
|
fields = [
|
||||||
|
"id", "ebook_id", "ebook_title", "author", "filename", "file_url",
|
||||||
|
"file_size", "cover_image", "format", "downloaded_at", "progress",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_filename(self, obj):
|
||||||
|
return obj.ebook.filename()
|
||||||
|
|
||||||
|
def get_file_url(self, obj):
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request and obj.ebook.file:
|
||||||
|
return request.build_absolute_uri(obj.ebook.file.url)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_progress(self, obj):
|
||||||
|
try:
|
||||||
|
rp = obj.ebook.reading_progress
|
||||||
|
return {
|
||||||
|
"current_position": rp.current_position,
|
||||||
|
"last_page": rp.last_page,
|
||||||
|
"epub_location": rp.epub_location,
|
||||||
|
}
|
||||||
|
except ReadingProgress.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StorageSummarySerializer(serializers.Serializer):
|
||||||
|
total_downloads = serializers.IntegerField()
|
||||||
|
total_size_bytes = serializers.IntegerField()
|
||||||
|
ebooks = serializers.ListField(child=serializers.DictField())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from apps.books.models import EBook
|
||||||
|
|
||||||
|
|
||||||
|
def subjects_from_ebook(ebook: EBook) -> list[str]:
|
||||||
|
ol = (ebook.metadata_json or {}).get("openlibrary") or {}
|
||||||
|
return [s.strip() for s in (ol.get("subjects") or []) if isinstance(s, str) and s.strip()]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
from apps.books.models import EBook
|
||||||
|
from apps.books.services.openlibrary import (
|
||||||
|
HIGH_CONFIDENCE,
|
||||||
|
download_cover,
|
||||||
|
fetch_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_ebook_metadata(ebook: EBook) -> EBook:
|
||||||
|
"""Fetch Open Library metadata and update the ebook. Never raises to callers."""
|
||||||
|
user_title = ebook.title
|
||||||
|
user_author = ebook.author or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = fetch_metadata(user_title, user_author)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Open Library metadata fetch failed for ebook %s", ebook.pk)
|
||||||
|
return ebook
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return ebook
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
metadata = {
|
||||||
|
"source": "openlibrary",
|
||||||
|
"matched_at": now,
|
||||||
|
"match_language": result.match_language,
|
||||||
|
"match_score": result.match_score,
|
||||||
|
"match_status": result.match_status,
|
||||||
|
"user_input": {"title": user_title, "author": user_author},
|
||||||
|
"openlibrary": result.openlibrary,
|
||||||
|
}
|
||||||
|
ebook.metadata_json = metadata
|
||||||
|
|
||||||
|
update_fields = ["metadata_json", "updated_at"]
|
||||||
|
|
||||||
|
if result.match_status == "not_found":
|
||||||
|
ebook.save(update_fields=update_fields)
|
||||||
|
return ebook
|
||||||
|
|
||||||
|
ol = result.openlibrary
|
||||||
|
cover_id = ol.get("cover_id")
|
||||||
|
|
||||||
|
cover_missing = (
|
||||||
|
not ebook.cover_image
|
||||||
|
or not ebook.cover_image.name
|
||||||
|
or not ebook.cover_image.storage.exists(ebook.cover_image.name)
|
||||||
|
)
|
||||||
|
if cover_id and cover_missing:
|
||||||
|
if ebook.cover_image:
|
||||||
|
ebook.cover_image.delete(save=False)
|
||||||
|
cover_bytes = download_cover(int(cover_id))
|
||||||
|
if cover_bytes:
|
||||||
|
filename = f"ol_cover_{ebook.pk}_{cover_id}.jpg"
|
||||||
|
ebook.cover_image.save(filename, ContentFile(cover_bytes), save=False)
|
||||||
|
update_fields.append("cover_image")
|
||||||
|
|
||||||
|
if result.match_score >= HIGH_CONFIDENCE:
|
||||||
|
ol_title = ol.get("title")
|
||||||
|
ol_authors = ol.get("authors") or []
|
||||||
|
if ol_title:
|
||||||
|
ebook.title = ol_title[:512]
|
||||||
|
update_fields.append("title")
|
||||||
|
if ol_authors:
|
||||||
|
ebook.author = ol_authors[0][:256]
|
||||||
|
update_fields.append("author")
|
||||||
|
|
||||||
|
ebook.save(update_fields=list(dict.fromkeys(update_fields)))
|
||||||
|
return ebook
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from config.settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SEARCH_URL = "https://openlibrary.org/search.json"
|
||||||
|
COVERS_URL = "https://covers.openlibrary.org/b/id/{cover_id}-{size}.jpg"
|
||||||
|
SEARCH_FIELDS = (
|
||||||
|
"key,title,author_name,cover_i,first_publish_year,subject,language,"
|
||||||
|
"edition_key,number_of_pages_median,publisher"
|
||||||
|
)
|
||||||
|
|
||||||
|
HIGH_CONFIDENCE = 0.8
|
||||||
|
LOW_CONFIDENCE = 0.6
|
||||||
|
|
||||||
|
# EPUB release noise often copied from filenames, e.g. "Title [6494] (r2.3)"
|
||||||
|
_TITLE_NOISE_PATTERNS = (
|
||||||
|
re.compile(r"\s*\[\d+\]"), # [6494]
|
||||||
|
re.compile(r"\s*\([rv][\d.]+\)", re.IGNORECASE), # (r2.3), (v1.0)
|
||||||
|
re.compile(r"\s*\(rev[\d.]*\)", re.IGNORECASE), # (rev2)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_search_title(title: str) -> str:
|
||||||
|
cleaned = title.strip()
|
||||||
|
for pattern in _TITLE_NOISE_PATTERNS:
|
||||||
|
cleaned = pattern.sub("", cleaned)
|
||||||
|
return re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenLibraryHit:
|
||||||
|
work_key: str
|
||||||
|
title: str
|
||||||
|
authors: list[str]
|
||||||
|
cover_id: int | None
|
||||||
|
first_publish_year: int | None
|
||||||
|
subjects: list[str]
|
||||||
|
languages: list[str]
|
||||||
|
publishers: list[str]
|
||||||
|
edition_key: str | None
|
||||||
|
number_of_pages_median: int | None
|
||||||
|
match_language: str
|
||||||
|
score: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenLibraryMetadata:
|
||||||
|
match_language: str
|
||||||
|
match_score: float
|
||||||
|
match_status: str
|
||||||
|
openlibrary: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(text: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^\w\s]", " ", _strip_accents(text).lower())
|
||||||
|
return re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_accents(text: str) -> str:
|
||||||
|
normalized = unicodedata.normalize("NFKD", text)
|
||||||
|
return "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_search_params(
|
||||||
|
title: str,
|
||||||
|
author: str,
|
||||||
|
*,
|
||||||
|
lang: str | None,
|
||||||
|
search_mode: str,
|
||||||
|
) -> dict[str, str | int]:
|
||||||
|
title = title.strip()
|
||||||
|
author = author.strip()
|
||||||
|
params: dict[str, str | int] = {"limit": 10, "fields": SEARCH_FIELDS}
|
||||||
|
|
||||||
|
if search_mode == "spanish_q":
|
||||||
|
q_parts = ["language:spa", title]
|
||||||
|
if author:
|
||||||
|
q_parts.append(author)
|
||||||
|
params["q"] = " ".join(q_parts)
|
||||||
|
elif search_mode == "q":
|
||||||
|
params["q"] = " ".join(part for part in (title, author) if part)
|
||||||
|
elif search_mode == "q_unaccent":
|
||||||
|
params["q"] = " ".join(
|
||||||
|
part for part in (_strip_accents(title), _strip_accents(author) if author else "") if part
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
params["title"] = title
|
||||||
|
if author:
|
||||||
|
params["author"] = author
|
||||||
|
|
||||||
|
if lang:
|
||||||
|
params["lang"] = lang
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def _title_similarity(a: str, b: str) -> float:
|
||||||
|
na, nb = _normalize(a), _normalize(b)
|
||||||
|
if not na or not nb:
|
||||||
|
return 0.0
|
||||||
|
if na in nb or nb in na:
|
||||||
|
return 1.0
|
||||||
|
return SequenceMatcher(None, na, nb).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def _author_overlap(user_author: str, ol_authors: list[str]) -> float:
|
||||||
|
if not user_author.strip():
|
||||||
|
return 0.5 if ol_authors else 0.0
|
||||||
|
user_tokens = set(_normalize(user_author).split())
|
||||||
|
if not user_tokens:
|
||||||
|
return 0.0
|
||||||
|
best = 0.0
|
||||||
|
for name in ol_authors:
|
||||||
|
name_tokens = set(_normalize(name).split())
|
||||||
|
if not name_tokens:
|
||||||
|
continue
|
||||||
|
overlap = len(user_tokens & name_tokens) / len(user_tokens)
|
||||||
|
best = max(best, overlap)
|
||||||
|
if user_tokens <= name_tokens or name_tokens <= user_tokens:
|
||||||
|
best = max(best, 0.95)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _score_hit(title: str, author: str, doc: dict[str, Any], *, lang: str) -> float:
|
||||||
|
ol_title = doc.get("title") or ""
|
||||||
|
ol_authors = doc.get("author_name") or []
|
||||||
|
title_score = _title_similarity(title, ol_title)
|
||||||
|
author_score = _author_overlap(author, ol_authors)
|
||||||
|
combined = (title_score * 0.6) + (author_score * 0.4)
|
||||||
|
if doc.get("cover_i"):
|
||||||
|
combined += 0.05
|
||||||
|
return min(combined, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hit(doc: dict[str, Any], *, lang: str, score: float) -> OpenLibraryHit:
|
||||||
|
edition_keys = doc.get("edition_key") or []
|
||||||
|
edition_key = edition_keys[0] if edition_keys else None
|
||||||
|
cover_id = doc.get("cover_i")
|
||||||
|
return OpenLibraryHit(
|
||||||
|
work_key=doc.get("key") or "",
|
||||||
|
title=doc.get("title") or "",
|
||||||
|
authors=list(doc.get("author_name") or []),
|
||||||
|
cover_id=int(cover_id) if cover_id else None,
|
||||||
|
first_publish_year=doc.get("first_publish_year"),
|
||||||
|
subjects=list(doc.get("subject") or [])[:10],
|
||||||
|
languages=list(doc.get("language") or []),
|
||||||
|
publishers=list(doc.get("publisher") or [])[:5],
|
||||||
|
edition_key=edition_key,
|
||||||
|
number_of_pages_median=doc.get("number_of_pages_median"),
|
||||||
|
match_language=lang,
|
||||||
|
score=score,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_cover_url(cover_id: int, size: str = "L") -> str:
|
||||||
|
return COVERS_URL.format(cover_id=cover_id, size=size)
|
||||||
|
|
||||||
|
|
||||||
|
def _client() -> httpx.Client:
|
||||||
|
read_timeout = settings.OPENLIBRARY_TIMEOUT_SECONDS
|
||||||
|
connect_timeout = settings.OPENLIBRARY_CONNECT_TIMEOUT_SECONDS
|
||||||
|
return httpx.Client(
|
||||||
|
timeout=httpx.Timeout(
|
||||||
|
connect=connect_timeout,
|
||||||
|
read=read_timeout,
|
||||||
|
write=read_timeout,
|
||||||
|
pool=connect_timeout,
|
||||||
|
),
|
||||||
|
headers={"User-Agent": settings.OPENLIBRARY_USER_AGENT},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_works(
|
||||||
|
title: str,
|
||||||
|
author: str,
|
||||||
|
*,
|
||||||
|
lang: str | None = None,
|
||||||
|
search_mode: str = "title",
|
||||||
|
client: httpx.Client | None = None,
|
||||||
|
) -> list[OpenLibraryHit]:
|
||||||
|
if not title.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
params = _build_search_params(title, author, lang=lang, search_mode=search_mode)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if client is not None:
|
||||||
|
response = client.get(SEARCH_URL, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
docs = response.json().get("docs") or []
|
||||||
|
else:
|
||||||
|
with _client() as owned_client:
|
||||||
|
response = owned_client.get(SEARCH_URL, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
docs = response.json().get("docs") or []
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
logger.warning("Open Library search failed: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
hits: list[OpenLibraryHit] = []
|
||||||
|
for doc in docs:
|
||||||
|
score = _score_hit(title, author, doc, lang=lang or "")
|
||||||
|
if score < LOW_CONFIDENCE:
|
||||||
|
continue
|
||||||
|
hits.append(_parse_hit(doc, lang=lang or "", score=score))
|
||||||
|
hits.sort(key=lambda h: (h.score, h.cover_id is not None), reverse=True)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def pick_best_match(title: str, author: str, hits: list[OpenLibraryHit]) -> OpenLibraryHit | None:
|
||||||
|
return hits[0] if hits else None
|
||||||
|
|
||||||
|
|
||||||
|
def download_cover(cover_id: int) -> bytes | None:
|
||||||
|
url = build_cover_url(cover_id, size="L")
|
||||||
|
try:
|
||||||
|
with _client() as client:
|
||||||
|
response = client.get(url)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
response.raise_for_status()
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
return None
|
||||||
|
return response.content
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("Open Library cover download failed for %s: %s", cover_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _hit_to_openlibrary_dict(hit: OpenLibraryHit) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"work_key": hit.work_key,
|
||||||
|
"edition_key": hit.edition_key,
|
||||||
|
"title": hit.title,
|
||||||
|
"authors": hit.authors,
|
||||||
|
"cover_id": hit.cover_id,
|
||||||
|
"cover_url": build_cover_url(hit.cover_id) if hit.cover_id else None,
|
||||||
|
"first_publish_year": hit.first_publish_year,
|
||||||
|
"subjects": hit.subjects,
|
||||||
|
"languages": hit.languages,
|
||||||
|
"publishers": hit.publishers,
|
||||||
|
"number_of_pages_median": hit.number_of_pages_median,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_metadata(title: str, author: str) -> OpenLibraryMetadata | None:
|
||||||
|
if not settings.OPENLIBRARY_ENABLED:
|
||||||
|
return None
|
||||||
|
|
||||||
|
preferred = settings.OPENLIBRARY_PREFERRED_LANG
|
||||||
|
fallback = settings.OPENLIBRARY_FALLBACK_LANG
|
||||||
|
search_title = _sanitize_search_title(title)
|
||||||
|
|
||||||
|
if not search_title:
|
||||||
|
return OpenLibraryMetadata(
|
||||||
|
match_language=preferred,
|
||||||
|
match_score=0.0,
|
||||||
|
match_status="not_found",
|
||||||
|
openlibrary={},
|
||||||
|
)
|
||||||
|
|
||||||
|
search_plan: list[tuple[str, str | None]] = [
|
||||||
|
("spanish_q", preferred),
|
||||||
|
("title", preferred),
|
||||||
|
("q", None),
|
||||||
|
("q_unaccent", None),
|
||||||
|
]
|
||||||
|
if fallback != preferred:
|
||||||
|
search_plan.extend([("title", fallback), ("q", fallback)])
|
||||||
|
|
||||||
|
hits: list[OpenLibraryHit] = []
|
||||||
|
with _client() as client:
|
||||||
|
for search_mode, lang in search_plan:
|
||||||
|
hits = search_works(search_title, author, lang=lang, search_mode=search_mode, client=client)
|
||||||
|
if hits:
|
||||||
|
break
|
||||||
|
|
||||||
|
hit = pick_best_match(title, author, hits)
|
||||||
|
if not hit:
|
||||||
|
return OpenLibraryMetadata(
|
||||||
|
match_language=preferred,
|
||||||
|
match_score=0.0,
|
||||||
|
match_status="not_found",
|
||||||
|
openlibrary={},
|
||||||
|
)
|
||||||
|
|
||||||
|
status = "matched" if hit.score >= HIGH_CONFIDENCE else "partial"
|
||||||
|
return OpenLibraryMetadata(
|
||||||
|
match_language=hit.match_language,
|
||||||
|
match_score=hit.score,
|
||||||
|
match_status=status,
|
||||||
|
openlibrary=_hit_to_openlibrary_dict(hit),
|
||||||
|
)
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Extract TOC, metadata, and page count from uploaded EPUB/PDF files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from apps.books.models import BookChapter, EBook
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def process_ebook(file_path: str, original_filename: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Parse an e-book file and return format, metadata, TOC, and page count."""
|
||||||
|
ext = Path(original_filename or file_path).suffix.lower()
|
||||||
|
if ext == ".epub" or file_path.lower().endswith(".epub"):
|
||||||
|
return _process_epub(file_path)
|
||||||
|
if ext == ".pdf" or file_path.lower().endswith(".pdf"):
|
||||||
|
return _process_pdf(file_path)
|
||||||
|
return {"format": ext.lstrip(".") or "unknown", "page_count": 0, "metadata": {}, "toc": []}
|
||||||
|
|
||||||
|
|
||||||
|
def store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None:
|
||||||
|
"""Recursively store TOC entries as BookChapter records."""
|
||||||
|
for idx, entry in enumerate(toc):
|
||||||
|
BookChapter.objects.create(
|
||||||
|
ebook=ebook,
|
||||||
|
title=entry.get("title", "Untitled"),
|
||||||
|
index=parent_index + idx,
|
||||||
|
href=entry.get("href", ""),
|
||||||
|
children=entry.get("children", []),
|
||||||
|
)
|
||||||
|
children = entry.get("children", [])
|
||||||
|
if children:
|
||||||
|
store_chapters(ebook, children, parent_index + idx + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_processing_to_ebook(ebook: EBook) -> dict[str, Any]:
|
||||||
|
"""Run processing on an EBook instance and persist chapters + metadata."""
|
||||||
|
if not ebook.file:
|
||||||
|
raise ValueError("No file found for this e-book.")
|
||||||
|
|
||||||
|
file_path = ebook.file.path
|
||||||
|
result = process_ebook(file_path, original_filename=ebook.filename())
|
||||||
|
|
||||||
|
ebook.format = result.get("format", ebook.format)
|
||||||
|
ebook.page_count = result.get("page_count", 0)
|
||||||
|
file_metadata = result.get("metadata") or {}
|
||||||
|
if file_metadata:
|
||||||
|
merged = dict(ebook.metadata_json or {})
|
||||||
|
merged["file"] = file_metadata
|
||||||
|
ebook.metadata_json = merged
|
||||||
|
ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"])
|
||||||
|
|
||||||
|
raw_toc: list[dict[str, Any]] = result.get("toc", [])
|
||||||
|
BookChapter.objects.filter(ebook=ebook).delete()
|
||||||
|
store_chapters(ebook, raw_toc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": ebook.format,
|
||||||
|
"page_count": ebook.page_count,
|
||||||
|
"metadata": ebook.metadata_json,
|
||||||
|
"toc_count": len(raw_toc),
|
||||||
|
"status": "processed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _process_epub(file_path: str) -> dict[str, Any]:
|
||||||
|
from ebooklib import epub, ITEM_DOCUMENT
|
||||||
|
|
||||||
|
book = epub.read_epub(file_path)
|
||||||
|
metadata = _extract_epub_metadata(book)
|
||||||
|
toc = _extract_epub_toc(book)
|
||||||
|
|
||||||
|
if not toc:
|
||||||
|
toc = _fallback_toc_from_spine(book, ITEM_DOCUMENT)
|
||||||
|
|
||||||
|
flat_count = _count_toc_entries(toc)
|
||||||
|
page_count = flat_count or len(book.spine)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": "epub",
|
||||||
|
"page_count": page_count,
|
||||||
|
"metadata": metadata,
|
||||||
|
"toc": toc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _process_pdf(file_path: str) -> dict[str, Any]:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
reader = PdfReader(file_path)
|
||||||
|
page_count = len(reader.pages)
|
||||||
|
metadata = _extract_pdf_metadata(reader)
|
||||||
|
toc = _extract_pdf_outline(reader)
|
||||||
|
if not toc and page_count > 0:
|
||||||
|
toc = [
|
||||||
|
{"title": f"Page {i}", "href": f"pdf:page:{i}", "children": []}
|
||||||
|
for i in range(1, page_count + 1)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"format": "pdf",
|
||||||
|
"page_count": page_count,
|
||||||
|
"metadata": metadata,
|
||||||
|
"toc": toc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pdf_metadata(reader) -> dict[str, Any]:
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
info = reader.metadata
|
||||||
|
if not info:
|
||||||
|
return metadata
|
||||||
|
title = getattr(info, "title", None)
|
||||||
|
author = getattr(info, "author", None)
|
||||||
|
if title:
|
||||||
|
metadata["title"] = str(title)
|
||||||
|
if author:
|
||||||
|
metadata["author"] = str(author)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pdf_outline(reader) -> list[dict[str, Any]]:
|
||||||
|
outline = getattr(reader, "outline", None)
|
||||||
|
if not outline:
|
||||||
|
return []
|
||||||
|
return _walk_pdf_outline(reader, outline)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_pdf_outline(reader, outline: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(outline):
|
||||||
|
item = outline[i]
|
||||||
|
if isinstance(item, list):
|
||||||
|
if entries:
|
||||||
|
entries[-1]["children"] = _walk_pdf_outline(reader, item)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
title = getattr(item, "title", None) or "Section"
|
||||||
|
page_num = 1
|
||||||
|
try:
|
||||||
|
page_num = reader.get_destination_page_number(item) + 1
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not resolve PDF outline destination", exc_info=True)
|
||||||
|
entries.append({
|
||||||
|
"title": str(title),
|
||||||
|
"href": f"pdf:page:{page_num}",
|
||||||
|
"children": [],
|
||||||
|
})
|
||||||
|
i += 1
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_epub_metadata(book) -> dict[str, Any]:
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def first(namespace: str, name: str) -> str:
|
||||||
|
values = book.get_metadata(namespace, name)
|
||||||
|
if values:
|
||||||
|
return str(values[0][0])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
title = first("DC", "title")
|
||||||
|
if title:
|
||||||
|
metadata["title"] = title
|
||||||
|
creator = first("DC", "creator")
|
||||||
|
if creator:
|
||||||
|
metadata["author"] = creator
|
||||||
|
language = first("DC", "language")
|
||||||
|
if language:
|
||||||
|
metadata["language"] = language
|
||||||
|
identifier = first("DC", "identifier")
|
||||||
|
if identifier:
|
||||||
|
metadata["identifier"] = identifier
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_toc_item(item) -> dict[str, Any]:
|
||||||
|
from ebooklib import epub
|
||||||
|
|
||||||
|
if isinstance(item, epub.Link):
|
||||||
|
return {
|
||||||
|
"title": item.title or "Untitled",
|
||||||
|
"href": item.href or "",
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
if isinstance(item, tuple):
|
||||||
|
section, children = item
|
||||||
|
entry = {
|
||||||
|
"title": getattr(section, "title", None) or "Untitled",
|
||||||
|
"href": getattr(section, "href", None) or "",
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
for child in children:
|
||||||
|
entry["children"].append(_parse_toc_item(child))
|
||||||
|
return entry
|
||||||
|
if hasattr(item, "title"):
|
||||||
|
return {
|
||||||
|
"title": item.title or "Untitled",
|
||||||
|
"href": getattr(item, "href", "") or "",
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
return {"title": "Untitled", "href": "", "children": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_epub_toc(book) -> list[dict[str, Any]]:
|
||||||
|
return [_parse_toc_item(item) for item in book.toc]
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_toc_from_spine(book, item_document_type) -> list[dict[str, Any]]:
|
||||||
|
toc: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
chapter_num = 0
|
||||||
|
|
||||||
|
for spine_entry in book.spine:
|
||||||
|
item_id = spine_entry[0] if isinstance(spine_entry, tuple) else spine_entry
|
||||||
|
item = book.get_item_with_id(item_id)
|
||||||
|
if not item or item.get_type() != item_document_type:
|
||||||
|
continue
|
||||||
|
href = item.get_name() or ""
|
||||||
|
if not href or href in seen:
|
||||||
|
continue
|
||||||
|
seen.add(href)
|
||||||
|
chapter_num += 1
|
||||||
|
title = os.path.splitext(os.path.basename(href))[0] or f"Chapter {chapter_num}"
|
||||||
|
toc.append({"title": title.replace("_", " ").replace("-", " "), "href": href, "children": []})
|
||||||
|
|
||||||
|
return toc
|
||||||
|
|
||||||
|
|
||||||
|
def _count_toc_entries(toc: list[dict[str, Any]]) -> int:
|
||||||
|
count = 0
|
||||||
|
for entry in toc:
|
||||||
|
count += 1
|
||||||
|
count += _count_toc_entries(entry.get("children", []))
|
||||||
|
return count
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Tests for the Book search & discovery endpoints."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.books.models import Book, ReadingStatus
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client():
|
||||||
|
return APIClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user(django_user_model):
|
||||||
|
return django_user_model.objects.create_user(
|
||||||
|
email="reader@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(api_client, user):
|
||||||
|
api_client.force_authenticate(user=user)
|
||||||
|
return api_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def books():
|
||||||
|
books_data = [
|
||||||
|
Book.objects.create(
|
||||||
|
title="Dune",
|
||||||
|
author="Frank Herbert",
|
||||||
|
genre="Science Fiction",
|
||||||
|
reading_status=ReadingStatus.FINISHED,
|
||||||
|
total_pages=688,
|
||||||
|
description="A desert planet saga.",
|
||||||
|
),
|
||||||
|
Book.objects.create(
|
||||||
|
title="Neuromancer",
|
||||||
|
author="William Gibson",
|
||||||
|
genre="Science Fiction",
|
||||||
|
reading_status=ReadingStatus.READING,
|
||||||
|
total_pages=271,
|
||||||
|
description="Cyberpunk classic.",
|
||||||
|
),
|
||||||
|
Book.objects.create(
|
||||||
|
title="The Hobbit",
|
||||||
|
author="J.R.R. Tolkien",
|
||||||
|
genre="Fantasy",
|
||||||
|
reading_status=ReadingStatus.WANT_TO_READ,
|
||||||
|
total_pages=310,
|
||||||
|
description="A hobbit's adventure.",
|
||||||
|
),
|
||||||
|
Book.objects.create(
|
||||||
|
title="1984",
|
||||||
|
author="George Orwell",
|
||||||
|
genre="Dystopian",
|
||||||
|
reading_status=ReadingStatus.FINISHED,
|
||||||
|
total_pages=328,
|
||||||
|
description="Big Brother is watching.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return books_data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Search tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBookSearch:
|
||||||
|
"""Verify the search endpoint returns correct results."""
|
||||||
|
|
||||||
|
def test_search_by_title(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Dune"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Dune" in titles
|
||||||
|
assert "Neuromancer" not in titles
|
||||||
|
|
||||||
|
def test_search_by_author(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Tolkien"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "The Hobbit" in titles
|
||||||
|
|
||||||
|
def test_search_by_genre(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Fantasy"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "The Hobbit" in titles
|
||||||
|
|
||||||
|
def test_search_case_insensitive(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "dune"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert any(b["title"] == "Dune" for b in response.data["results"])
|
||||||
|
|
||||||
|
def test_search_partial_match(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Neu"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Neuromancer" in titles
|
||||||
|
|
||||||
|
def test_search_empty_query_returns_all(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": ""})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.data["results"]) == 4
|
||||||
|
|
||||||
|
def test_search_no_results(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "zzzznotfound"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert len(response.data["results"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filter tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBookFilters:
|
||||||
|
"""Verify filters for genre, author, and reading_status."""
|
||||||
|
|
||||||
|
def test_filter_by_genre(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"genre": "Science Fiction"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Dune" in titles
|
||||||
|
assert "Neuromancer" in titles
|
||||||
|
assert "The Hobbit" not in titles
|
||||||
|
|
||||||
|
def test_filter_by_author(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"author": "George Orwell"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "1984" in titles
|
||||||
|
assert "Dune" not in titles
|
||||||
|
|
||||||
|
def test_filter_by_reading_status(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"reading_status": ReadingStatus.FINISHED})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Dune" in titles
|
||||||
|
assert "1984" in titles
|
||||||
|
assert "Neuromancer" not in titles
|
||||||
|
assert "The Hobbit" not in titles
|
||||||
|
|
||||||
|
def test_filter_combined_with_search(self, auth_client, books):
|
||||||
|
"""Search + filter should intersect results."""
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"q": "Dune", "reading_status": ReadingStatus.FINISHED})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert "Dune" in titles
|
||||||
|
# 1984 matches reading_status but not search
|
||||||
|
assert "1984" not in titles
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discovery endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBookDiscovery:
|
||||||
|
"""Verify genre and author discovery endpoints."""
|
||||||
|
|
||||||
|
def test_genres_endpoint(self, auth_client, books):
|
||||||
|
url = reverse("book-genres")
|
||||||
|
response = auth_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert isinstance(response.data, list)
|
||||||
|
assert "Science Fiction" in response.data
|
||||||
|
assert "Fantasy" in response.data
|
||||||
|
assert "Dystopian" in response.data
|
||||||
|
# No duplicate genres
|
||||||
|
assert response.data.count("Science Fiction") == 1
|
||||||
|
|
||||||
|
def test_authors_endpoint(self, auth_client, books):
|
||||||
|
url = reverse("book-authors")
|
||||||
|
response = auth_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert isinstance(response.data, list)
|
||||||
|
assert "Frank Herbert" in response.data
|
||||||
|
assert "J.R.R. Tolkien" in response.data
|
||||||
|
|
||||||
|
def test_genres_requires_auth(self, api_client, books):
|
||||||
|
url = reverse("book-genres")
|
||||||
|
response = api_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
def test_authors_requires_auth(self, api_client, books):
|
||||||
|
url = reverse("book-authors")
|
||||||
|
response = api_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Detail view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBookDetail:
|
||||||
|
"""Verify the book detail endpoint."""
|
||||||
|
|
||||||
|
def test_retrieve_book(self, auth_client, books):
|
||||||
|
book = books[0]
|
||||||
|
url = reverse("book-detail", kwargs={"pk": book.pk})
|
||||||
|
response = auth_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data["title"] == "Dune"
|
||||||
|
assert response.data["author"] == "Frank Herbert"
|
||||||
|
assert response.data["description"] == "A desert planet saga."
|
||||||
|
assert response.data["total_pages"] == 688
|
||||||
|
|
||||||
|
def test_retrieve_nonexistent_returns_404(self, auth_client, books):
|
||||||
|
url = reverse("book-detail", kwargs={"pk": 99999})
|
||||||
|
response = auth_client.get(url)
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
def test_pagination(self, auth_client, books):
|
||||||
|
"""List with small page size should paginate."""
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(f"{url}?page_size=2")
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert "count" in response.data
|
||||||
|
assert "results" in response.data
|
||||||
|
assert response.data["count"] == 4
|
||||||
|
|
||||||
|
def test_ordering(self, auth_client, books):
|
||||||
|
url = reverse("book-list")
|
||||||
|
response = auth_client.get(url, {"ordering": "title"})
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
titles = [b["title"] for b in response.data["results"]]
|
||||||
|
assert titles == sorted(titles)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.books.views import BookViewSet, EBookViewSet, book_reading_settings_view
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"ebooks", EBookViewSet, basename="ebook")
|
||||||
|
router.register(r"", BookViewSet, basename="book")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("settings/", book_reading_settings_view, name="book-settings"),
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.db.models import QuerySet, Q
|
||||||
|
from django.http import FileResponse
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from rest_framework import parsers, permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import action, api_view, permission_classes
|
||||||
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress
|
||||||
|
from apps.books.serializers import (
|
||||||
|
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
|
||||||
|
BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
|
||||||
|
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
|
||||||
|
ReadingProgressSerializer, StorageSummarySerializer,
|
||||||
|
)
|
||||||
|
from apps.reader.models import ReadingSettings
|
||||||
|
from apps.books.services.ebook_metadata import subjects_from_ebook
|
||||||
|
from apps.books.services.metadata import enrich_ebook_metadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BookViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Book.objects.all()
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
|
filterset_fields = ["author", "genre", "reading_status"]
|
||||||
|
search_fields = ["title", "author", "genre"]
|
||||||
|
ordering_fields = ["title", "author", "genre", "created_at"]
|
||||||
|
ordering = ["title"]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "retrieve":
|
||||||
|
return BookDetailSerializer
|
||||||
|
if self.action == "list":
|
||||||
|
return BookListSerializer
|
||||||
|
return BookSerializer
|
||||||
|
|
||||||
|
def get_queryset(self) -> QuerySet[Book]:
|
||||||
|
qs = super().get_queryset()
|
||||||
|
query = self.request.query_params.get("q", "").strip()
|
||||||
|
if query:
|
||||||
|
qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query))
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
|
||||||
|
def genres(self, request: Request) -> Response:
|
||||||
|
genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre")
|
||||||
|
return Response([g for g in genre_list if g])
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
|
||||||
|
def authors(self, request: Request) -> Response:
|
||||||
|
author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author")
|
||||||
|
return Response([a for a in author_list if a])
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def storage(self, request: Request) -> Response:
|
||||||
|
"""Return storage usage summary for the current user."""
|
||||||
|
download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook")
|
||||||
|
total_size = sum(r.file_size for r in download_records)
|
||||||
|
ebook_list = [
|
||||||
|
{"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size}
|
||||||
|
for r in download_records
|
||||||
|
]
|
||||||
|
serializer = StorageSummarySerializer(data={
|
||||||
|
"total_downloads": download_records.count(),
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"ebooks": ebook_list,
|
||||||
|
})
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class IsEBookOwner(permissions.BasePermission):
|
||||||
|
def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool:
|
||||||
|
return obj.user == request.user
|
||||||
|
|
||||||
|
|
||||||
|
class EBookViewSet(viewsets.ModelViewSet):
|
||||||
|
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
|
||||||
|
permission_classes = [IsAuthenticated, IsEBookOwner]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return EBookUploadSerializer
|
||||||
|
if self.action in ("list",):
|
||||||
|
return EBookListSerializer
|
||||||
|
if self.action in ("toc",):
|
||||||
|
return BookChapterSerializer
|
||||||
|
return EBookDetailSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user")
|
||||||
|
query = self.request.query_params.get("q", "").strip()
|
||||||
|
if query and self.action == "list":
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(title__icontains=query) | Q(author__icontains=query),
|
||||||
|
)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def genres(self, request: Request) -> Response:
|
||||||
|
genre_set: set[str] = set()
|
||||||
|
for ebook in self.get_queryset():
|
||||||
|
genre_set.update(subjects_from_ebook(ebook))
|
||||||
|
return Response(sorted(genre_set))
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def authors(self, request: Request) -> Response:
|
||||||
|
author_list = (
|
||||||
|
self.get_queryset()
|
||||||
|
.values_list("author", flat=True)
|
||||||
|
.distinct()
|
||||||
|
.order_by("author")
|
||||||
|
)
|
||||||
|
return Response([a for a in author_list if a])
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get", "patch"])
|
||||||
|
def progress(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
ebook = self.get_object()
|
||||||
|
progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook)
|
||||||
|
if request.method == "GET":
|
||||||
|
serializer = ReadingProgressSerializer(progress_obj)
|
||||||
|
return Response(serializer.data)
|
||||||
|
serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"], url_path="enrich-metadata")
|
||||||
|
def enrich_metadata(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Re-fetch Open Library metadata and cover for this ebook."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
try:
|
||||||
|
enrich_ebook_metadata(ebook)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Manual metadata enrichment failed for ebook %s", ebook.id)
|
||||||
|
return Response(
|
||||||
|
{"error": "Metadata enrichment failed."},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
|
ebook.refresh_from_db()
|
||||||
|
serializer = EBookDetailSerializer(ebook, context={"request": request})
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def process(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
if not ebook.file:
|
||||||
|
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apps.books.services.process_ebook import apply_processing_to_ebook
|
||||||
|
|
||||||
|
result = apply_processing_to_ebook(ebook)
|
||||||
|
return Response(result)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to process ebook %s", ebook.id)
|
||||||
|
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
|
||||||
|
"""Stream the ebook file for authenticated in-browser reading."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
content_types = {
|
||||||
|
"epub": "application/epub+zip",
|
||||||
|
"pdf": "application/pdf",
|
||||||
|
}
|
||||||
|
content_type = content_types.get(ebook.format)
|
||||||
|
if not content_type:
|
||||||
|
return Response(
|
||||||
|
{"error": "Unsupported format for in-browser reading."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if not ebook.file:
|
||||||
|
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return FileResponse(
|
||||||
|
ebook.file.open("rb"),
|
||||||
|
content_type=content_type,
|
||||||
|
filename=ebook.filename(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def toc(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Return hierarchical table of contents."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
|
||||||
|
serializer = BookChapterSerializer(chapters, many=True)
|
||||||
|
return Response({
|
||||||
|
"chapters": serializer.data,
|
||||||
|
"format": ebook.format,
|
||||||
|
"page_count": ebook.page_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"])
|
||||||
|
def content(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Return paginated content for a given page number.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
page (int): page/chapter index to fetch (1-indexed, default: 1)
|
||||||
|
"""
|
||||||
|
ebook = self.get_object()
|
||||||
|
page = max(1, int(request.query_params.get("page", 1)))
|
||||||
|
|
||||||
|
chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook"))
|
||||||
|
total_pages = len(chapters) or ebook.page_count or 1
|
||||||
|
|
||||||
|
chapter: BookChapter | None = None
|
||||||
|
chapter_title = ""
|
||||||
|
content_html = ""
|
||||||
|
|
||||||
|
if chapters and 0 <= page - 1 < len(chapters):
|
||||||
|
ch = chapters[page - 1]
|
||||||
|
chapter_title = ch.title
|
||||||
|
content_html = _fetch_chapter_content(ebook, ch)
|
||||||
|
|
||||||
|
serializer = EBookContentSerializer(data={
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"content": content_html,
|
||||||
|
"chapter_title": chapter_title,
|
||||||
|
"format": ebook.format,
|
||||||
|
})
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def download(self, request: Request, pk: int | None = None) -> Response:
|
||||||
|
"""Track download of an e-book. Creates a DownloadRecord and returns file info."""
|
||||||
|
ebook = self.get_object()
|
||||||
|
if not ebook.file:
|
||||||
|
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
download, created = DownloadRecord.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
ebook=ebook,
|
||||||
|
defaults={"file_size": ebook.file.size if ebook.file else 0},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
download.file_size = ebook.file.size if ebook.file else 0
|
||||||
|
download.save(update_fields=["file_size"])
|
||||||
|
|
||||||
|
serializer = DownloadRecordSerializer(download, context={"request": request})
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def downloads(self, request: Request) -> Response:
|
||||||
|
"""List all e-books the current user has downloaded."""
|
||||||
|
records = DownloadRecord.objects.filter(user=request.user).select_related(
|
||||||
|
"ebook", "ebook__reading_progress"
|
||||||
|
).prefetch_related("ebook__chapters")
|
||||||
|
page = self.paginate_queryset(records)
|
||||||
|
if page is not None:
|
||||||
|
serializer = DownloadRecordSerializer(page, many=True, context={"request": request})
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
serializer = DownloadRecordSerializer(records, many=True, context={"request": request})
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["delete"], url_path="downloads/(?P<download_pk>[^/.]+)")
|
||||||
|
def delete_download(self, request: Request, download_pk: str | None = None) -> Response:
|
||||||
|
"""Delete a download record."""
|
||||||
|
try:
|
||||||
|
download = DownloadRecord.objects.get(pk=download_pk, user=request.user)
|
||||||
|
except DownloadRecord.DoesNotExist:
|
||||||
|
return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
download.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
|
||||||
|
"""Fetch HTML content for a chapter from the e-book file."""
|
||||||
|
if ebook.format == "epub":
|
||||||
|
return _fetch_epub_chapter_content(ebook.file.path, chapter)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
|
||||||
|
"""Extract HTML content of a specific EPUB chapter by href."""
|
||||||
|
try:
|
||||||
|
from ebooklib import epub
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
except ImportError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
book = epub.read_epub(file_path)
|
||||||
|
href = chapter.href or ""
|
||||||
|
# Find the item by href
|
||||||
|
for item in book.get_items():
|
||||||
|
item_name = item.get_name() or ""
|
||||||
|
if href and (item_name.endswith(href) or href.endswith(item_name)):
|
||||||
|
content = item.get_content()
|
||||||
|
soup = BeautifulSoup(content, "html.parser")
|
||||||
|
# Clean up — remove body/html/head wrappers, keep inner content
|
||||||
|
body = soup.find("body")
|
||||||
|
if body:
|
||||||
|
return str(body)
|
||||||
|
return str(soup)
|
||||||
|
return ""
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET", "PATCH"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def book_reading_settings_view(request: Request) -> Response:
|
||||||
|
"""Get or update reading settings via the books API contract."""
|
||||||
|
settings, _created = ReadingSettings.objects.get_or_create(user=request.user)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
serializer = BookReadingSettingsSerializer(settings)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data)
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from .models import Collection
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Collection)
|
|
||||||
class CollectionAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["name", "owner", "document_count", "is_public", "created_at"]
|
|
||||||
list_filter = ["is_public"]
|
|
||||||
search_fields = ["name", "description"]
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Collection(models.Model):
|
|
||||||
"""A user-created collection of documents."""
|
|
||||||
name = models.CharField(max_length=300)
|
|
||||||
description = models.TextField(blank=True, default="")
|
|
||||||
cover = models.ImageField(upload_to="collection_covers/", blank=True, null=True)
|
|
||||||
documents = models.ManyToManyField(
|
|
||||||
"documents.Document",
|
|
||||||
related_name="collections",
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
is_public = models.BooleanField(default=False)
|
|
||||||
owner = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="collections",
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "collections_collection"
|
|
||||||
ordering = ["-updated_at"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def document_count(self) -> int:
|
|
||||||
return self.documents.count()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cover_url(self) -> str | None:
|
|
||||||
if self.cover:
|
|
||||||
return self.cover.url
|
|
||||||
return None
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from .models import Collection
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionSerializer(serializers.ModelSerializer[Collection]):
|
|
||||||
cover_url = serializers.SerializerMethodField()
|
|
||||||
document_count = serializers.IntegerField(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Collection
|
|
||||||
fields = [
|
|
||||||
"id", "name", "description", "cover_url", "document_count",
|
|
||||||
"is_public", "created_at", "updated_at",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "document_count", "created_at", "updated_at"]
|
|
||||||
|
|
||||||
def get_cover_url(self, obj: Collection) -> str | None:
|
|
||||||
return obj.cover_url
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionDetailSerializer(CollectionSerializer):
|
|
||||||
documents = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta(CollectionSerializer.Meta):
|
|
||||||
fields = CollectionSerializer.Meta.fields + ["documents", "owner"]
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionDocumentActionSerializer(serializers.Serializer):
|
|
||||||
document_ids = serializers.ListField(
|
|
||||||
child=serializers.IntegerField(),
|
|
||||||
allow_empty=False,
|
|
||||||
)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from django.urls import include, path
|
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register("", views.CollectionViewSet, basename="collection")
|
|
||||||
|
|
||||||
app_name = "collections"
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("", include(router.urls)),
|
|
||||||
]
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
from rest_framework import permissions, status, viewsets
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from apps.documents.models import Document
|
|
||||||
|
|
||||||
from .models import Collection
|
|
||||||
from .serializers import (
|
|
||||||
CollectionDetailSerializer,
|
|
||||||
CollectionDocumentActionSerializer,
|
|
||||||
CollectionSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionViewSet(viewsets.ModelViewSet):
|
|
||||||
"""CRUD for user collections."""
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action in ("retrieve", "update", "partial_update"):
|
|
||||||
return CollectionDetailSerializer
|
|
||||||
return CollectionSerializer
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return Collection.objects.filter(owner=self.request.user).prefetch_related("documents")
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
serializer.save(owner=self.request.user)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def add_documents(self, request, pk=None):
|
|
||||||
"""Add documents to a collection."""
|
|
||||||
collection = self.get_object()
|
|
||||||
serializer = CollectionDocumentActionSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
docs = Document.objects.filter(
|
|
||||||
id__in=serializer.validated_data["document_ids"],
|
|
||||||
owner=request.user,
|
|
||||||
)
|
|
||||||
collection.documents.add(*docs)
|
|
||||||
return Response({"detail": f"Added {docs.count()} document(s)."}, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def remove_documents(self, request, pk=None):
|
|
||||||
"""Remove documents from a collection."""
|
|
||||||
collection = self.get_object()
|
|
||||||
serializer = CollectionDocumentActionSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
collection.documents.remove(*serializer.validated_data["document_ids"])
|
|
||||||
return Response({"detail": "Documents removed."}, status=status.HTTP_200_OK)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from .models import Document
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Document)
|
|
||||||
class DocumentAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["title", "author", "file_type", "file_size", "is_public", "owner", "uploaded_at"]
|
|
||||||
list_filter = ["file_type", "is_public"]
|
|
||||||
search_fields = ["title", "author", "description"]
|
|
||||||
date_hierarchy = "uploaded_at"
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Document(models.Model):
|
|
||||||
"""A digital document (ebook, PDF, etc.) uploaded by a user."""
|
|
||||||
|
|
||||||
class FileType(models.TextChoices):
|
|
||||||
PDF = "pdf", "PDF"
|
|
||||||
EPUB = "epub", "EPUB"
|
|
||||||
MOBI = "mobi", "MOBI"
|
|
||||||
TXT = "txt", "Plain Text"
|
|
||||||
DOCX = "docx", "Word Document"
|
|
||||||
|
|
||||||
title = models.CharField(max_length=500)
|
|
||||||
author = models.CharField(max_length=300, blank=True, null=True)
|
|
||||||
description = models.TextField(blank=True, default="")
|
|
||||||
cover = models.ImageField(upload_to="covers/", blank=True, null=True)
|
|
||||||
file = models.FileField(upload_to="documents/")
|
|
||||||
file_type = models.CharField(max_length=10, choices=FileType.choices)
|
|
||||||
file_size = models.PositiveIntegerField(help_text="File size in bytes")
|
|
||||||
page_count = models.PositiveIntegerField(blank=True, null=True)
|
|
||||||
tags = models.JSONField(default=list, blank=True)
|
|
||||||
is_public = models.BooleanField(default=False)
|
|
||||||
owner = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="documents",
|
|
||||||
)
|
|
||||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "documents_document"
|
|
||||||
ordering = ["-uploaded_at"]
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["owner", "-uploaded_at"]),
|
|
||||||
models.Index(fields=["file_type"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.title
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cover_url(self) -> str | None:
|
|
||||||
if self.cover:
|
|
||||||
return self.cover.url
|
|
||||||
return None
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from .models import Document
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentListSerializer(serializers.ModelSerializer[Document]):
|
|
||||||
cover_url = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Document
|
|
||||||
fields = [
|
|
||||||
"id", "title", "author", "cover_url", "description",
|
|
||||||
"file_type", "file_size", "page_count", "tags",
|
|
||||||
"is_public", "uploaded_at", "updated_at",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "uploaded_at", "updated_at"]
|
|
||||||
|
|
||||||
def get_cover_url(self, obj: Document) -> str | None:
|
|
||||||
return obj.cover_url
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentDetailSerializer(DocumentListSerializer):
|
|
||||||
owner = serializers.PrimaryKeyRelatedField(read_only=True)
|
|
||||||
|
|
||||||
class Meta(DocumentListSerializer.Meta):
|
|
||||||
fields = DocumentListSerializer.Meta.fields + ["owner", "file"]
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentUploadSerializer(serializers.ModelSerializer[Document]):
|
|
||||||
class Meta:
|
|
||||||
model = Document
|
|
||||||
fields = [
|
|
||||||
"title", "author", "description", "cover", "file",
|
|
||||||
"file_type", "file_size", "page_count", "tags", "is_public",
|
|
||||||
]
|
|
||||||
|
|
||||||
def validate_file_size(self, value: int) -> int:
|
|
||||||
max_size = 100 * 1024 * 1024 # 100 MB
|
|
||||||
if value > max_size:
|
|
||||||
raise serializers.ValidationError("File size must not exceed 100 MB.")
|
|
||||||
return value
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from django.urls import include, path
|
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register("", views.DocumentViewSet, basename="document")
|
|
||||||
|
|
||||||
app_name = "documents"
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("", include(router.urls)),
|
|
||||||
]
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from rest_framework import permissions, viewsets
|
|
||||||
|
|
||||||
from .models import Document
|
|
||||||
from .serializers import DocumentDetailSerializer, DocumentListSerializer, DocumentUploadSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class IsOwnerOrPublic(permissions.BasePermission):
|
|
||||||
"""Allow access if user is owner or the document is public."""
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj: Document) -> bool:
|
|
||||||
if request.method in permissions.SAFE_METHODS:
|
|
||||||
return obj.is_public or obj.owner == request.user
|
|
||||||
return obj.owner == request.user
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentViewSet(viewsets.ModelViewSet):
|
|
||||||
"""CRUD for documents with owner-scoping."""
|
|
||||||
permission_classes = [permissions.IsAuthenticated, IsOwnerOrPublic]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action == "create":
|
|
||||||
return DocumentUploadSerializer
|
|
||||||
if self.action in ("retrieve", "update", "partial_update"):
|
|
||||||
return DocumentDetailSerializer
|
|
||||||
return DocumentListSerializer
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
user = self.request.user
|
|
||||||
qs = Document.objects.select_related("owner")
|
|
||||||
if self.action == "list":
|
|
||||||
return qs.filter(owner=user) | qs.filter(is_public=True)
|
|
||||||
return qs
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
serializer.save(owner=self.request.user)
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReaderConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.reader"
|
||||||
|
verbose_name = "Reader Settings"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-05-29 06:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '__first__'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReadingSettings',
|
||||||
|
fields=[
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='reading_settings', serialize=False, to=settings.AUTH_USER_MODEL)),
|
||||||
|
('font_family', models.CharField(choices=[('sans-serif', 'Sans-serif'), ('serif', 'Serif'), ('monospace', 'Monospace')], default='serif', max_length=32)),
|
||||||
|
('font_size', models.PositiveSmallIntegerField(default=18)),
|
||||||
|
('line_height', models.FloatField(default=1.6)),
|
||||||
|
('margin_width', models.PositiveSmallIntegerField(default=16)),
|
||||||
|
('background_color', models.CharField(default='#f5f0eb', max_length=7)),
|
||||||
|
('text_color', models.CharField(default='#1a1a1a', max_length=7)),
|
||||||
|
('brightness', models.PositiveSmallIntegerField(default=100)),
|
||||||
|
('orientation_lock', models.CharField(choices=[('auto', 'Auto'), ('portrait', 'Portrait'), ('landscape', 'Landscape')], default='auto', max_length=16)),
|
||||||
|
('theme', models.CharField(choices=[('sepia', 'Sepia'), ('dark', 'Dark'), ('light', 'Light'), ('paper', 'Paper')], default='sepia', max_length=32)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Reading Settings',
|
||||||
|
'verbose_name_plural': 'Reading Settings',
|
||||||
|
'db_table': 'reader_reading_settings',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingSettings(models.Model):
|
||||||
|
"""Per-user reading preferences for the e-book reader view."""
|
||||||
|
|
||||||
|
THEME_CHOICES = [
|
||||||
|
("sepia", "Sepia"),
|
||||||
|
("dark", "Dark"),
|
||||||
|
("light", "Light"),
|
||||||
|
("paper", "Paper"),
|
||||||
|
]
|
||||||
|
|
||||||
|
FONT_CHOICES = [
|
||||||
|
("sans-serif", "Sans-serif"),
|
||||||
|
("serif", "Serif"),
|
||||||
|
("monospace", "Monospace"),
|
||||||
|
]
|
||||||
|
|
||||||
|
ORIENTATION_CHOICES = [
|
||||||
|
("auto", "Auto"),
|
||||||
|
("portrait", "Portrait"),
|
||||||
|
("landscape", "Landscape"),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="reading_settings",
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
font_family = models.CharField(max_length=32, choices=FONT_CHOICES, default="serif")
|
||||||
|
font_size = models.PositiveSmallIntegerField(default=18)
|
||||||
|
line_height = models.FloatField(default=1.6)
|
||||||
|
margin_width = models.PositiveSmallIntegerField(default=16)
|
||||||
|
background_color = models.CharField(max_length=7, default="#f5f0eb")
|
||||||
|
text_color = models.CharField(max_length=7, default="#1a1a1a")
|
||||||
|
brightness = models.PositiveSmallIntegerField(default=100)
|
||||||
|
orientation_lock = models.CharField(
|
||||||
|
max_length=16, choices=ORIENTATION_CHOICES, default="auto"
|
||||||
|
)
|
||||||
|
theme = models.CharField(max_length=32, choices=THEME_CHOICES, default="sepia")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "reader_reading_settings"
|
||||||
|
verbose_name = "Reading Settings"
|
||||||
|
verbose_name_plural = "Reading Settings"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.user} — {self.theme} ({self.font_size}px)"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.reader.models import ReadingSettings
|
||||||
|
|
||||||
|
# Theme presets mapped to colors
|
||||||
|
THEME_COLORS = {
|
||||||
|
"sepia": {"background_color": "#f5f0eb", "text_color": "#1a1a1a"},
|
||||||
|
"dark": {"background_color": "#1a1a2e", "text_color": "#e0e0e0"},
|
||||||
|
"light": {"background_color": "#ffffff", "text_color": "#1a1a1a"},
|
||||||
|
"paper": {"background_color": "#e8e0d4", "text_color": "#2c2c2c"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingSettingsSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize ReadingSettings for the current user."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ReadingSettings
|
||||||
|
fields = [
|
||||||
|
"font_family",
|
||||||
|
"font_size",
|
||||||
|
"line_height",
|
||||||
|
"margin_width",
|
||||||
|
"background_color",
|
||||||
|
"text_color",
|
||||||
|
"brightness",
|
||||||
|
"orientation_lock",
|
||||||
|
"theme",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
def validate_font_size(self, value: int) -> int:
|
||||||
|
if value < 12 or value > 32:
|
||||||
|
raise serializers.ValidationError("Font size must be between 12 and 32.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_line_height(self, value: float) -> float:
|
||||||
|
if value < 1.2 or value > 2.0:
|
||||||
|
raise serializers.ValidationError("Line height must be between 1.2 and 2.0.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_margin_width(self, value: int) -> int:
|
||||||
|
if value < 8 or value > 48:
|
||||||
|
raise serializers.ValidationError("Margin width must be between 8 and 48.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_brightness(self, value: int) -> int:
|
||||||
|
if value < 0 or value > 100:
|
||||||
|
raise serializers.ValidationError("Brightness must be between 0 and 100.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Sync theme colors when theme changes, unless explicit colors provided."""
|
||||||
|
theme = attrs.get("theme")
|
||||||
|
if theme and theme in THEME_COLORS:
|
||||||
|
# Only auto-set colors if not explicitly provided
|
||||||
|
if "background_color" not in attrs:
|
||||||
|
attrs["background_color"] = THEME_COLORS[theme]["background_color"]
|
||||||
|
if "text_color" not in attrs:
|
||||||
|
attrs["text_color"] = THEME_COLORS[theme]["text_color"]
|
||||||
|
return attrs
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.reader.views import reading_settings_view
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("settings/", reading_settings_view, name="reading-settings"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from rest_framework import permissions, status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.reader.models import ReadingSettings
|
||||||
|
from apps.reader.serializers import ReadingSettingsSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET", "PUT", "PATCH"])
|
||||||
|
@permission_classes([permissions.IsAuthenticated])
|
||||||
|
def reading_settings_view(request: Request) -> Response:
|
||||||
|
"""Get or update the current user's reading settings.
|
||||||
|
|
||||||
|
GET → return existing settings (auto-create defaults if missing)
|
||||||
|
PUT → create or fully replace settings
|
||||||
|
PATCH → partial update
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
settings, created = ReadingSettings.objects.get_or_create(user=user)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
serializer = ReadingSettingsSerializer(settings)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
if request.method == "PUT":
|
||||||
|
serializer = ReadingSettingsSerializer(settings, data=request.data)
|
||||||
|
elif request.method == "PATCH":
|
||||||
|
serializer = ReadingSettingsSerializer(settings, data=request.data, partial=True)
|
||||||
|
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from .models import Bookmark, Highlight, ReadingProgress
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Bookmark)
|
|
||||||
class BookmarkAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["document", "user", "page", "label", "created_at"]
|
|
||||||
list_filter = ["created_at"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Highlight)
|
|
||||||
class HighlightAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["document", "user", "page", "color", "created_at"]
|
|
||||||
list_filter = ["color", "created_at"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ReadingProgress)
|
|
||||||
class ReadingProgressAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["document", "user", "percentage", "last_read_at"]
|
|
||||||
date_hierarchy = "last_read_at"
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Bookmark(models.Model):
|
|
||||||
"""A user bookmark at a specific page in a document."""
|
|
||||||
document = models.ForeignKey(
|
|
||||||
"documents.Document",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="bookmarks",
|
|
||||||
)
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="bookmarks",
|
|
||||||
)
|
|
||||||
page = models.PositiveIntegerField()
|
|
||||||
label = models.CharField(max_length=300, blank=True, default="")
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "reading_bookmark"
|
|
||||||
ordering = ["page"]
|
|
||||||
unique_together = [["document", "user", "page"]]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.document.title} p.{self.page}"
|
|
||||||
|
|
||||||
|
|
||||||
class Highlight(models.Model):
|
|
||||||
"""A highlighted passage in a document."""
|
|
||||||
document = models.ForeignKey(
|
|
||||||
"documents.Document",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="highlights",
|
|
||||||
)
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="highlights",
|
|
||||||
)
|
|
||||||
page = models.PositiveIntegerField()
|
|
||||||
color = models.CharField(max_length=20, default="yellow")
|
|
||||||
text = models.TextField()
|
|
||||||
note = models.TextField(blank=True, null=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "reading_highlight"
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"Highlight on {self.document.title} p.{self.page}"
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingProgress(models.Model):
|
|
||||||
"""Tracks the user's reading progress through a document."""
|
|
||||||
document = models.ForeignKey(
|
|
||||||
"documents.Document",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="reading_progress",
|
|
||||||
)
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="reading_progress",
|
|
||||||
)
|
|
||||||
current_page = models.PositiveIntegerField(default=1)
|
|
||||||
total_pages = models.PositiveIntegerField(default=0)
|
|
||||||
percentage = models.FloatField(default=0.0)
|
|
||||||
last_read_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "reading_progress"
|
|
||||||
unique_together = [["document", "user"]]
|
|
||||||
verbose_name_plural = "Reading progress"
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.document.title} — {self.percentage:.0f}%"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if self.total_pages > 0:
|
|
||||||
self.percentage = round((self.current_page / self.total_pages) * 100, 1)
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from .models import Bookmark, Highlight, ReadingProgress
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSerializer(serializers.ModelSerializer[Bookmark]):
|
|
||||||
class Meta:
|
|
||||||
model = Bookmark
|
|
||||||
fields = ["id", "document", "page", "label", "created_at"]
|
|
||||||
read_only_fields = ["id", "created_at"]
|
|
||||||
|
|
||||||
|
|
||||||
class HighlightSerializer(serializers.ModelSerializer[Highlight]):
|
|
||||||
class Meta:
|
|
||||||
model = Highlight
|
|
||||||
fields = ["id", "document", "page", "color", "text", "note", "created_at"]
|
|
||||||
read_only_fields = ["id", "created_at"]
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingProgressSerializer(serializers.ModelSerializer[ReadingProgress]):
|
|
||||||
class Meta:
|
|
||||||
model = ReadingProgress
|
|
||||||
fields = ["id", "document", "current_page", "total_pages", "percentage", "last_read_at"]
|
|
||||||
read_only_fields = ["id", "percentage", "last_read_at"]
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingProgressUpdateSerializer(serializers.ModelSerializer[ReadingProgress]):
|
|
||||||
class Meta:
|
|
||||||
model = ReadingProgress
|
|
||||||
fields = ["current_page", "total_pages"]
|
|
||||||
|
|
||||||
def validate_current_page(self, value: int) -> int:
|
|
||||||
if value < 1:
|
|
||||||
raise serializers.ValidationError("Page must be at least 1.")
|
|
||||||
return value
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
from django.urls import include, path
|
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register("bookmarks", views.BookmarkViewSet, basename="bookmark")
|
|
||||||
router.register("highlights", views.HighlightViewSet, basename="highlight")
|
|
||||||
router.register("progress", views.ReadingProgressViewSet, basename="reading-progress")
|
|
||||||
|
|
||||||
app_name = "reading"
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("", include(router.urls)),
|
|
||||||
]
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from rest_framework import permissions, viewsets
|
|
||||||
|
|
||||||
from .models import Bookmark, Highlight, ReadingProgress
|
|
||||||
from .serializers import (
|
|
||||||
BookmarkSerializer,
|
|
||||||
HighlightSerializer,
|
|
||||||
ReadingProgressSerializer,
|
|
||||||
ReadingProgressUpdateSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkViewSet(viewsets.ModelViewSet):
|
|
||||||
"""User bookmarks for documents."""
|
|
||||||
serializer_class = BookmarkSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return Bookmark.objects.filter(user=self.request.user).select_related("document")
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
serializer.save(user=self.request.user)
|
|
||||||
|
|
||||||
|
|
||||||
class HighlightViewSet(viewsets.ModelViewSet):
|
|
||||||
"""User highlights for documents."""
|
|
||||||
serializer_class = HighlightSerializer
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return Highlight.objects.filter(user=self.request.user).select_related("document")
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
serializer.save(user=self.request.user)
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingProgressViewSet(viewsets.ModelViewSet):
|
|
||||||
"""Reading progress tracker."""
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action in ("create", "update", "partial_update"):
|
|
||||||
return ReadingProgressUpdateSerializer
|
|
||||||
return ReadingProgressSerializer
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return ReadingProgress.objects.filter(user=self.request.user).select_related("document")
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
|
||||||
serializer.save(user=self.request.user)
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
|
|
||||||
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(BaseUserAdmin):
|
||||||
|
"""Admin config for the custom User model."""
|
||||||
|
list_display = ("email", "username", "is_staff", "is_active", "date_joined")
|
||||||
|
search_fields = ("email", "username")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UsersConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.users"
|
||||||
|
label = "users"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2026-06-03 22:26
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='User',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'User',
|
||||||
|
'verbose_name_plural': 'Users',
|
||||||
|
'db_table': 'users_user',
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractUser):
|
||||||
|
"""Custom user model. Uses email as the unique identifier field."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "users_user"
|
||||||
|
verbose_name = "User"
|
||||||
|
verbose_name_plural = "Users"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.email or self.username
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_username(email: str) -> str:
|
||||||
|
local = email.split("@", 1)[0]
|
||||||
|
candidate = re.sub(r"[^\w.@+-]", "_", local).strip("._")
|
||||||
|
return candidate[:150] if candidate else "user"
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_username(base: str) -> str:
|
||||||
|
username = base[:150]
|
||||||
|
if not User.objects.filter(username=username).exists():
|
||||||
|
return username
|
||||||
|
suffix = 1
|
||||||
|
while User.objects.filter(username=f"{username[:140]}_{suffix}").exists():
|
||||||
|
suffix += 1
|
||||||
|
return f"{username[:140]}_{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.Serializer):
|
||||||
|
email = serializers.EmailField()
|
||||||
|
password = serializers.CharField(write_only=True, min_length=8)
|
||||||
|
|
||||||
|
def validate_email(self, value: str) -> str:
|
||||||
|
email = value.lower()
|
||||||
|
if User.objects.filter(email__iexact=email).exists():
|
||||||
|
raise serializers.ValidationError("A user with this email already exists.")
|
||||||
|
return email
|
||||||
|
|
||||||
|
def validate_password(self, value: str) -> str:
|
||||||
|
validate_password(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(self, validated_data: dict) -> User:
|
||||||
|
email = validated_data["email"]
|
||||||
|
username = _unique_username(_derive_username(email))
|
||||||
|
return User.objects.create_user(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
password=validated_data["password"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_representation(self, instance: User) -> dict:
|
||||||
|
return {
|
||||||
|
"id": instance.id,
|
||||||
|
"email": instance.email,
|
||||||
|
"username": instance.username,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields.pop(self.username_field, None)
|
||||||
|
self.fields["email"] = serializers.EmailField(required=True)
|
||||||
|
|
||||||
|
def validate(self, attrs: dict) -> dict:
|
||||||
|
email = attrs.get("email", "").lower()
|
||||||
|
password = attrs.get("password")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email__iexact=email)
|
||||||
|
except User.DoesNotExist as exc:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"detail": "No active account found with the given credentials."}
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not user.check_password(password):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"detail": "No active account found with the given credentials."}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise serializers.ValidationError({"detail": "User account is disabled."})
|
||||||
|
|
||||||
|
refresh = self.get_token(user)
|
||||||
|
return {
|
||||||
|
"refresh": str(refresh),
|
||||||
|
"access": str(refresh.access_token),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_token(cls, user: User) -> object:
|
||||||
|
token = super().get_token(user)
|
||||||
|
token["email"] = user.email
|
||||||
|
return token
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
|
|
||||||
|
from apps.users.views import EmailTokenObtainPairView, RegisterView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("register/", RegisterView.as_view(), name="register"),
|
||||||
|
path("token/", EmailTokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||||
|
path("login/", EmailTokenObtainPairView.as_view(), name="login"),
|
||||||
|
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from rest_framework import generics
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
|
||||||
|
from apps.users.serializers import EmailTokenObtainPairSerializer, RegisterSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterView(generics.CreateAPIView):
|
||||||
|
serializer_class = RegisterSerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class EmailTokenObtainPairView(TokenObtainPairView):
|
||||||
|
serializer_class = EmailTokenObtainPairSerializer
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""Custom exception handler that returns consistent JSON error responses."""
|
|
||||||
from rest_framework.views import exception_handler
|
|
||||||
|
|
||||||
|
|
||||||
def custom_exception_handler(exc, context):
|
|
||||||
"""Wrap DRF's default handler to always return {'detail': ..., 'code': ...}."""
|
|
||||||
response = exception_handler(exc, context)
|
|
||||||
if response is not None:
|
|
||||||
data = response.data
|
|
||||||
# Flatten validation errors into a consistent shape
|
|
||||||
if isinstance(data, dict) and "detail" not in data:
|
|
||||||
response.data = {"detail": "Validation error", "fields": data, "code": "validation_error"}
|
|
||||||
return response
|
|
||||||
+36
-159
@@ -1,170 +1,47 @@
|
|||||||
import os
|
from pydantic_settings import BaseSettings
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import decouple
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings via pydantic-settings. Reads from env vars and .env."""
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Django
|
||||||
# Environment
|
DJANGO_SECRET_KEY: str = "django-insecure-change-me-in-production"
|
||||||
# ---------------------------------------------------------------------------
|
DJANGO_DEBUG: bool = False
|
||||||
config = decouple.AutoConfig(search_path=BASE_DIR / ".env")
|
DJANGO_ALLOWED_HOSTS: list[str] = ["*"]
|
||||||
|
|
||||||
SECRET_KEY = config("SECRET_KEY", default="django-insecure-change-me-in-production")
|
# PostgreSQL
|
||||||
DEBUG = config("DEBUG", default=False, cast=bool)
|
DB_NAME: str = "cloud_reader"
|
||||||
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=decouple.Csv())
|
DB_USER: str = "postgres"
|
||||||
|
DB_PASSWORD: str = "postgres"
|
||||||
|
DB_HOST: str = "localhost"
|
||||||
|
DB_PORT: int = 5432
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# JWT
|
||||||
# Application definition
|
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",
|
|
||||||
"rest_framework_simplejwt",
|
|
||||||
"corsheaders",
|
|
||||||
"django_filters",
|
|
||||||
"drf_spectacular",
|
|
||||||
# Local
|
|
||||||
"apps.accounts",
|
|
||||||
"apps.documents",
|
|
||||||
"apps.collections",
|
|
||||||
"apps.reading",
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
# CORS
|
||||||
"django.middleware.security.SecurityMiddleware",
|
CORS_ALLOWED_ORIGINS: list[str] = [
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"http://localhost:5173",
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
"http://localhost:3000",
|
||||||
"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"
|
# 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)"
|
||||||
|
|
||||||
TEMPLATES = [
|
@property
|
||||||
{
|
def DATABASE_URL(self) -> str:
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
return (
|
||||||
"DIRS": [BASE_DIR / "templates"],
|
f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}"
|
||||||
"APP_DIRS": True,
|
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||||
"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"
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Database
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": config("DB_ENGINE", default="django.db.backends.postgresql"),
|
|
||||||
"NAME": config("DB_NAME", default="cloud_reader"),
|
|
||||||
"USER": config("DB_USER", default="cloud_reader"),
|
|
||||||
"PASSWORD": config("DB_PASSWORD", default="cloud_reader"),
|
|
||||||
"HOST": config("DB_HOST", default="localhost"),
|
|
||||||
"PORT": config("DB_PORT", default="5432", cast=int),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
settings = Settings()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Auth
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
AUTH_USER_MODEL = "accounts.User"
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
|
||||||
]
|
|
||||||
|
|
||||||
LOGIN_URL = "rest_framework:login"
|
|
||||||
LOGOUT_URL = "rest_framework:logout"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internationalization
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
LANGUAGE_CODE = "en-us"
|
|
||||||
TIME_ZONE = "UTC"
|
|
||||||
USE_I18N = True
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Static & Media files
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
STATIC_URL = "static/"
|
|
||||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
|
||||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
|
||||||
|
|
||||||
MEDIA_URL = "media/"
|
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CORS
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
CORS_ALLOWED_ORIGINS = config(
|
|
||||||
"CORS_ALLOWED_ORIGINS",
|
|
||||||
default="http://localhost:5173,http://localhost:3000",
|
|
||||||
cast=decouple.Csv(),
|
|
||||||
)
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# REST Framework
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
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": 20,
|
|
||||||
"DEFAULT_FILTER_BACKENDS": [
|
|
||||||
"django_filters.rest_framework.DjangoFilterBackend",
|
|
||||||
"rest_framework.filters.SearchFilter",
|
|
||||||
"rest_framework.filters.OrderingFilter",
|
|
||||||
],
|
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
|
||||||
"EXCEPTION_HANDLER": "config.exceptions.custom_exception_handler",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SimpleJWT
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
from datetime import timedelta # noqa: E402
|
|
||||||
|
|
||||||
SIMPLE_JWT = {
|
|
||||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
|
|
||||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
|
||||||
"ROTATE_REFRESH_TOKENS": True,
|
|
||||||
"AUTH_HEADER_TYPES": ("Bearer",),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# drf-spectacular (OpenAPI)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SPECTACULAR_SETTINGS = {
|
|
||||||
"TITLE": "Cloud Reader API",
|
|
||||||
"VERSION": "0.1.0",
|
|
||||||
"SERVE_INCLUDE_SCHEMA": False,
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-7
@@ -1,13 +1,16 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
# API
|
path("api/auth/", include("apps.users.urls")),
|
||||||
path("api/v1/auth/", include("apps.accounts.urls")),
|
path("api/books/", include("apps.books.urls")),
|
||||||
path("api/v1/documents/", include("apps.documents.urls")),
|
path("api/annotations/", include("apps.annotations.urls")),
|
||||||
path("api/v1/collections/", include("apps.collections.urls")),
|
path("api/reader/", include("apps.reader.urls")),
|
||||||
path("api/v1/reading/", include("apps.reading.urls")),
|
|
||||||
# OpenAPI schema
|
|
||||||
path("api/schema/", include("drf_spectacular.urls")),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
@@ -2,6 +2,6 @@ import os
|
|||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
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()
|
application = get_wsgi_application()
|
||||||
+3
-2
@@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
"""Django's command-line utility for administrative tasks."""
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main():
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
|
|||||||
+20
-28
@@ -1,33 +1,25 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "cloud-reader-backend"
|
name = "backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Cloud Reader API — Django REST Framework backend"
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=5.1,<6.0",
|
"coverage==7.6.10",
|
||||||
"djangorestframework>=3.15,<4.0",
|
"django==5.1.7",
|
||||||
"django-cors-headers>=4.3",
|
"django-cors-headers==4.6.0",
|
||||||
"django-filter>=24.3",
|
"django-filter==25.1",
|
||||||
"psycopg2-binary>=2.9",
|
"djangorestframework==3.15.2",
|
||||||
"python-decouple>=3.8",
|
"djangorestframework-simplejwt==5.4.0",
|
||||||
"djangorestframework-simplejwt>=5.3",
|
"gunicorn==23.0.0",
|
||||||
"drf-spectacular>=0.27",
|
"pillow>=11.0.0",
|
||||||
"gunicorn>=22.0",
|
"psycopg2-binary==2.9.10",
|
||||||
"whitenoise>=6.6",
|
"pydantic==2.10.5",
|
||||||
"Pillow>=10.3",
|
"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",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-django>=4.8",
|
|
||||||
"pytest-cov>=5.0",
|
|
||||||
"model-bakery>=1.17",
|
|
||||||
"ruff>=0.5",
|
|
||||||
"ipdb>=0.13",
|
|
||||||
]
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools>=72"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
+7
-4
@@ -1,5 +1,8 @@
|
|||||||
# pytest
|
[tool:pytest]
|
||||||
DJANGO_SETTINGS_MODULE = config.settings
|
DJANGO_SETTINGS_MODULE = config.django
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
django_find_project = false
|
testpaths = apps
|
||||||
testpaths = apps/
|
|
||||||
|
[coverage:run]
|
||||||
|
source = apps
|
||||||
|
omit = */tests/*,*/migrations/*,*/admin.py,*/apps.py
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-r production.txt
|
|
||||||
pytest>=8.0
|
|
||||||
pytest-django>=4.8
|
|
||||||
pytest-cov>=5.0
|
|
||||||
model-bakery>=1.17
|
|
||||||
ruff>=0.5
|
|
||||||
ipdb>=0.13
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Django
|
|
||||||
django>=5.1,<6.0
|
|
||||||
djangorestframework>=3.15,<4.0
|
|
||||||
django-cors-headers>=4.3
|
|
||||||
django-filter>=24.3
|
|
||||||
psycopg2-binary>=2.9
|
|
||||||
python-decouple>=3.8
|
|
||||||
djangorestframework-simplejwt>=5.3
|
|
||||||
drf-spectacular>=0.27
|
|
||||||
gunicorn>=22.0
|
|
||||||
whitenoise>=6.6
|
|
||||||
Pillow>=10.3
|
|
||||||
Generated
+673
@@ -0,0 +1,673 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asgiref"
|
||||||
|
version = "3.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "django-cors-headers" },
|
||||||
|
{ name = "django-filter" },
|
||||||
|
{ name = "djangorestframework" },
|
||||||
|
{ name = "djangorestframework-simplejwt" },
|
||||||
|
{ name = "ebooklib" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "psycopg2-binary" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pypdf" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-django" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
|
||||||
|
{ name = "coverage", specifier = "==7.6.10" },
|
||||||
|
{ name = "django", specifier = "==5.1.7" },
|
||||||
|
{ name = "django-cors-headers", specifier = "==4.6.0" },
|
||||||
|
{ name = "django-filter", specifier = "==25.1" },
|
||||||
|
{ name = "djangorestframework", specifier = "==3.15.2" },
|
||||||
|
{ name = "djangorestframework-simplejwt", specifier = "==5.4.0" },
|
||||||
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
|
{ name = "gunicorn", specifier = "==23.0.0" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28.0" },
|
||||||
|
{ name = "pillow", specifier = ">=11.0.0" },
|
||||||
|
{ name = "psycopg2-binary", specifier = "==2.9.10" },
|
||||||
|
{ name = "pydantic", specifier = "==2.10.5" },
|
||||||
|
{ name = "pydantic-settings", specifier = "==2.7.1" },
|
||||||
|
{ name = "pypdf", specifier = ">=5.0.0" },
|
||||||
|
{ name = "pytest", specifier = "==8.3.4" },
|
||||||
|
{ name = "pytest-cov", specifier = "==6.0.0" },
|
||||||
|
{ name = "pytest-django", specifier = "==4.9.0" },
|
||||||
|
{ name = "python-dotenv", specifier = "==1.0.1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.14.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.5.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.6.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868, upload-time = "2024-12-26T16:59:18.734Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281, upload-time = "2024-12-26T16:57:42.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514, upload-time = "2024-12-26T16:57:45.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537, upload-time = "2024-12-26T16:57:48.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572, upload-time = "2024-12-26T16:57:51.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639, upload-time = "2024-12-26T16:57:53.175Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072, upload-time = "2024-12-26T16:57:56.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386, upload-time = "2024-12-26T16:57:57.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054, upload-time = "2024-12-26T16:57:58.967Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904, upload-time = "2024-12-26T16:58:00.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692, upload-time = "2024-12-26T16:58:02.35Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308, upload-time = "2024-12-26T16:58:04.487Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565, upload-time = "2024-12-26T16:58:06.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083, upload-time = "2024-12-26T16:58:10.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235, upload-time = "2024-12-26T16:58:12.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220, upload-time = "2024-12-26T16:58:15.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847, upload-time = "2024-12-26T16:58:17.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922, upload-time = "2024-12-26T16:58:20.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783, upload-time = "2024-12-26T16:58:23.614Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965, upload-time = "2024-12-26T16:58:26.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719, upload-time = "2024-12-26T16:58:28.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050, upload-time = "2024-12-26T16:58:31.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321, upload-time = "2024-12-26T16:58:34.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039, upload-time = "2024-12-26T16:58:36.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758, upload-time = "2024-12-26T16:58:39.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119, upload-time = "2024-12-26T16:58:41.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597, upload-time = "2024-12-26T16:58:42.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473, upload-time = "2024-12-26T16:58:44.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737, upload-time = "2024-12-26T16:58:45.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611, upload-time = "2024-12-26T16:58:47.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781, upload-time = "2024-12-26T16:58:50.822Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django"
|
||||||
|
version = "5.1.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "sqlparse" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/57/11186e493ddc5a5e92cc7924a6363f7d4c2b645f7d7cb04a26a63f9bfb8b/Django-5.1.7.tar.gz", hash = "sha256:30de4ee43a98e5d3da36a9002f287ff400b43ca51791920bfb35f6917bfe041c", size = 10716510, upload-time = "2025-03-06T12:52:18.938Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/0f/7e042df3d462d39ae01b27a09ee76653692442bc3701fbfa6cb38e12889d/Django-5.1.7-py3-none-any.whl", hash = "sha256:1323617cb624add820cb9611cdcc788312d250824f92ca6048fda8625514af2b", size = 8276912, upload-time = "2025-03-06T12:52:12.784Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-cors-headers"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c6/e5/3b67fc05b9c02b926411436dfc553829bc00843706ce7f99752433017f47/django_cors_headers-4.6.0.tar.gz", hash = "sha256:14d76b4b4c8d39375baeddd89e4f08899051eeaf177cb02a29bd6eae8cf63aa8", size = 20961, upload-time = "2024-10-29T10:38:15.281Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/73/689532cf164ab10ed1521d825ea156656520cec98886c8d2ac1ce8829220/django_cors_headers-4.6.0-py3-none-any.whl", hash = "sha256:8edbc0497e611c24d5150e0055d3b178c6534b8ed826fb6f53b21c63f5d48ba3", size = 12791, upload-time = "2024-10-29T10:38:13.784Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-filter"
|
||||||
|
version = "25.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/40/c702a6fe8cccac9bf426b55724ebdf57d10a132bae80a17691d0cf0b9bac/django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153", size = 143021, upload-time = "2025-02-14T16:30:53.238Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/a6/70dcd68537c434ba7cb9277d403c5c829caf04f35baf5eb9458be251e382/django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80", size = 94114, upload-time = "2025-02-14T16:30:50.435Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "djangorestframework"
|
||||||
|
version = "3.15.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "djangorestframework-simplejwt"
|
||||||
|
version = "5.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "djangorestframework" },
|
||||||
|
{ name = "pyjwt" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8d/5f/1c130e823f734ba776c3925ad8e7c28ca1a59feb22d039b3810d1f8c0b34/djangorestframework_simplejwt-5.4.0.tar.gz", hash = "sha256:cccecce1a0e1a4a240fae80da73e5fc23055bababb8b67de88fa47cd36822320", size = 96648, upload-time = "2025-01-07T08:25:11.667Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/41/d6f67d24f46c7d8ee6dcb85c9abb94bad23140210bfb8c454641d278dfd2/djangorestframework_simplejwt-5.4.0-py3-none-any.whl", hash = "sha256:7aec953db9ed4163430c16d086eecb0f028f814ce6bba62b06c25919261e9077", size = 102316, upload-time = "2025-01-07T08:24:30.503Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ebooklib"
|
||||||
|
version = "0.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "lxml" },
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/77/85/322e8882a582d4b707220d1929cfb74c125f2ba513991edbce40dbc462de/ebooklib-0.20.tar.gz", hash = "sha256:35e2f9d7d39907be8d39ae2deb261b19848945903ae3dbb6577b187ead69e985", size = 127066, upload-time = "2025-10-26T20:56:20.968Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/ee/aa015c5de8b0dc42a8e507eae8c2de5d1c0e068c896858fec6d502402ed6/ebooklib-0.20-py3-none-any.whl", hash = "sha256:fff5322517a37e31c972d27be7d982cc3928c16b3dcc5fd7e8f7c0f5d7bcf42b", size = 40995, upload-time = "2025-10-26T20:56:19.104Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gunicorn"
|
||||||
|
version = "23.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lxml"
|
||||||
|
version = "6.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg2-binary"
|
||||||
|
version = "2.9.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.10.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6a/c7/ca334c2ef6f2e046b1144fe4bb2a5da8a4c574e7f2ebf7e16b34a6a2fa92/pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff", size = 761287, upload-time = "2025-01-09T13:33:25.929Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/26/82663c79010b28eddf29dcdd0ea723439535fa917fce5905885c0e9ba562/pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53", size = 431426, upload-time = "2025-01-09T13:33:22.312Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.27.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-settings"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920, upload-time = "2024-12-31T11:27:44.632Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718, upload-time = "2024-12-31T11:27:43.201Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyjwt"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pypdf"
|
||||||
|
version = "6.12.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0a/6d/20879428577c1e57ecd41b69dc86beabf43db9287ad2e702207f8b48c751/pypdf-6.12.2.tar.gz", hash = "sha256:111669eb6680c04495ae0c113a1476e3bf93a95761d23c7406b591c80a6490b1", size = 6468184, upload-time = "2026-05-26T13:31:26.911Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/44/fee070a16639d9869bb6a7e0f3a1b3946da1d66f32b9260b4d19cb90d7b2/pypdf-6.12.2-py3-none-any.whl", hash = "sha256:67b2699357a1f3f4c945940ea80826349ee507c9e2577724a14b4941982c104d", size = 343865, upload-time = "2026-05-26T13:31:25.068Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "8.3.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-django"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067, upload-time = "2024-09-02T15:49:18.407Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723, upload-time = "2024-09-02T15:49:17.127Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.8.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparse"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2026.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||||
|
]
|
||||||
+39
-21
@@ -1,39 +1,57 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:15
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: cloud_reader
|
POSTGRES_DB: ${POSTGRES_DB:-cloud_reader}
|
||||||
POSTGRES_USER: cloud_reader
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: cloud_reader
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
healthcheck:
|
||||||
- pgdata:/var/lib/postgresql/data
|
test: ["CMD-SHELL", "pg_isready -U postgres -d cloud_reader"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: backend
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
SECRET_KEY: development-secret-key
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||||
DEBUG: "True"
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-True}
|
||||||
DB_HOST: db
|
DB_NAME: ${POSTGRES_DB:-cloud_reader}
|
||||||
DB_NAME: cloud_reader
|
DB_USER: ${POSTGRES_USER:-postgres}
|
||||||
DB_USER: cloud_reader
|
DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
DB_PASSWORD: cloud_reader
|
DB_HOST: ${DB_HOST:-db}
|
||||||
CORS_ALLOWED_ORIGINS: http://localhost:5173,http://localhost:3000
|
DB_PORT: ${DB_PORT:-5432}
|
||||||
ALLOWED_HOSTS: localhost,127.0.0.1
|
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:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: frontend
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "5173:80"
|
- "5173:5173"
|
||||||
|
environment:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
postgres_data:
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 010 — Open Library metadata on import
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-03
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
After a user uploads an EPUB/PDF (`POST /api/books/ebooks/`), enrich the `EBook` with metadata and a cover from [Open Library](https://openlibrary.org/developers/api), using only **title** and **author** from the upload form. Prefer **Spanish** editions when available; fall back to English/any.
|
||||||
|
|
||||||
|
Upload must **never fail** if Open Library is down or no match is found.
|
||||||
|
|
||||||
|
## Trigger
|
||||||
|
|
||||||
|
- **Automatic:** `EBookUploadSerializer.create()` calls `enrich_ebook_metadata(ebook)` after file save.
|
||||||
|
- **Manual:** `POST /api/books/ebooks/{id}/enrich-metadata/` re-runs enrichment (owner only).
|
||||||
|
|
||||||
|
## Open Library usage
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
`GET https://openlibrary.org/search.json`
|
||||||
|
|
||||||
|
| Param | Value |
|
||||||
|
|-------|--------|
|
||||||
|
| `title` | User-provided title |
|
||||||
|
| `author` | User-provided author |
|
||||||
|
| `lang` | `es` (primary) or `en` (fallback) |
|
||||||
|
| `limit` | `5` |
|
||||||
|
| `fields` | `key,title,author_name,cover_i,first_publish_year,subject,language,edition_key,number_of_pages_median,publisher` |
|
||||||
|
|
||||||
|
Primary pass also uses query filter `language:spa`. Fallback omits language filter.
|
||||||
|
|
||||||
|
### Covers
|
||||||
|
|
||||||
|
`GET https://covers.openlibrary.org/b/id/{cover_i}-L.jpg` — downloaded and stored on `EBook.cover_image`.
|
||||||
|
|
||||||
|
## Match scoring
|
||||||
|
|
||||||
|
| Score | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| ≥ 0.8 | Apply OL title/author + cover + full metadata |
|
||||||
|
| 0.6 – 0.8 | Metadata + cover only; keep user title/author |
|
||||||
|
| < 0.6 | `match_status: not_found`; no field changes except metadata stub |
|
||||||
|
|
||||||
|
Author overlap + title similarity (normalized strings, `difflib.SequenceMatcher`). Prefer hits with `cover_i`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
No migration. Uses existing fields on `EBook`:
|
||||||
|
|
||||||
|
- `metadata_json` — full enrichment payload (see below)
|
||||||
|
- `cover_image` — downloaded cover file
|
||||||
|
- `title` / `author` — updated when match score ≥ 0.8
|
||||||
|
|
||||||
|
### `metadata_json` shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": "openlibrary",
|
||||||
|
"matched_at": "2026-06-03T12:00:00+00:00",
|
||||||
|
"match_language": "es",
|
||||||
|
"match_score": 0.92,
|
||||||
|
"match_status": "matched",
|
||||||
|
"user_input": { "title": "...", "author": "..." },
|
||||||
|
"openlibrary": {
|
||||||
|
"work_key": "/works/OL...",
|
||||||
|
"edition_key": "...",
|
||||||
|
"title": "...",
|
||||||
|
"authors": ["..."],
|
||||||
|
"cover_id": 12345,
|
||||||
|
"cover_url": "https://covers.openlibrary.org/b/id/12345-L.jpg",
|
||||||
|
"first_publish_year": 1605,
|
||||||
|
"subjects": ["..."],
|
||||||
|
"languages": ["spa"],
|
||||||
|
"publishers": ["..."],
|
||||||
|
"number_of_pages_median": 320
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
|
||||||
|
### Upload response (unchanged path)
|
||||||
|
|
||||||
|
`POST /api/books/ebooks/` — response may include populated `cover_image` and updated `title`/`author` after sync enrichment.
|
||||||
|
|
||||||
|
### Detail
|
||||||
|
|
||||||
|
`GET /api/books/ebooks/{id}/` — adds read-only `metadata` (alias of `metadata_json`).
|
||||||
|
|
||||||
|
### Manual refresh
|
||||||
|
|
||||||
|
`POST /api/books/ebooks/{id}/enrich-metadata/` — returns updated `EBookDetailSerializer` payload.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Env var | Default | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `OPENLIBRARY_ENABLED` | `true` | Kill switch |
|
||||||
|
| `OPENLIBRARY_PREFERRED_LANG` | `es` | Primary `lang` param |
|
||||||
|
| `OPENLIBRARY_FALLBACK_LANG` | `en` | Fallback `lang` param |
|
||||||
|
| `OPENLIBRARY_TIMEOUT_SECONDS` | `5` | HTTP timeout |
|
||||||
|
| `OPENLIBRARY_USER_AGENT` | `CloudReader/1.0` | User-Agent header |
|
||||||
|
|
||||||
|
## Code layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/apps/books/services/
|
||||||
|
├── openlibrary.py # Search, scoring, cover download
|
||||||
|
└── metadata.py # enrich_ebook_metadata orchestrator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Upload with title `Don Quijote`, author `Cervantes` → cover + Spanish-friendly metadata.
|
||||||
|
2. Upload with nonsense title/author → 201, no cover, `match_status: not_found`.
|
||||||
|
3. `POST .../enrich-metadata/` on existing ebook refreshes metadata.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Book Search & Discovery — Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Enable users to search books within the library and discover new books via filters and a dedicated detail view.
|
||||||
|
|
||||||
|
## Backend API Contracts
|
||||||
|
|
||||||
|
### Book List & Search
|
||||||
|
`GET /api/books/`
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `q` | string | Full-text search across title, author, genre |
|
||||||
|
| `genre` | string | Exact filter by genre |
|
||||||
|
| `author` | string | Exact filter by author |
|
||||||
|
| `reading_status` | string | Filter: `want_to_read`, `reading`, `finished`, `dnf` |
|
||||||
|
| `ordering` | string | `title`, `author`, `genre`, `created_at` (prefix `-` for desc) |
|
||||||
|
| `page` | int | Page number (default: 1) |
|
||||||
|
|
||||||
|
**Response (paginated):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 42,
|
||||||
|
"next": "http://.../?page=2",
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Dune",
|
||||||
|
"author": "Frank Herbert",
|
||||||
|
"genre": "Science Fiction",
|
||||||
|
"reading_status": "finished",
|
||||||
|
"reading_status_display": "Finished",
|
||||||
|
"cover_image": "https://..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Book Detail
|
||||||
|
`GET /api/books/{id}/`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Dune",
|
||||||
|
"author": "Frank Herbert",
|
||||||
|
"genre": "Science Fiction",
|
||||||
|
"description": "...",
|
||||||
|
"reading_status": "finished",
|
||||||
|
"reading_status_display": "Finished",
|
||||||
|
"cover_image": "https://...",
|
||||||
|
"total_pages": 688,
|
||||||
|
"created_at": "2025-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2025-01-15T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Genre / Author Discovery
|
||||||
|
`GET /api/books/genres/` → `["Fiction", "Science Fiction", ...]`
|
||||||
|
|
||||||
|
`GET /api/books/authors/` → `["Frank Herbert", "Ursula K. Le Guin", ...]`
|
||||||
|
|
||||||
|
## Frontend Components
|
||||||
|
|
||||||
|
### LibraryPage (enhanced)
|
||||||
|
- **Search bar** at top: text input with debounced `onChange` → calls API with `q` param
|
||||||
|
- **Filter row**: genre dropdown, author dropdown, reading status dropdown
|
||||||
|
- Genre/Author dropdowns populated from `/api/books/genres/` and `/api/books/authors/`
|
||||||
|
- Reading status uses static enum values (`READING_STATUS_OPTIONS`)
|
||||||
|
- **Results grid**: card layout showing cover, title, author, reading status badge
|
||||||
|
- **Empty state**: "No books found" with clear message when results are empty
|
||||||
|
- **Loading state**: spinner/skeleton while fetching
|
||||||
|
- **Click card → navigate to** `/books/{id}`
|
||||||
|
|
||||||
|
### BookDetailPage (new)
|
||||||
|
- Shows full book info: cover, title, author, genre, description, reading status, total pages
|
||||||
|
- Back button to return to library
|
||||||
|
- Clean, mobile-responsive layout
|
||||||
|
|
||||||
|
### API Client — `frontend/src/api/books.ts`
|
||||||
|
|
||||||
|
| Method | Endpoint | Returns |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| `searchBooks(params)` | `GET /api/books/` | `{count, results: BookListItem[]}` |
|
||||||
|
| `getBook(id)` | `GET /api/books/{id}/` | `BookDetail` |
|
||||||
|
| `getGenres()` | `GET /api/books/genres/` | `string[]` |
|
||||||
|
| `getAuthors()` | `GET /api/books/authors/` | `string[]` |
|
||||||
|
|
||||||
|
## Routes (Frontend)
|
||||||
|
| Path | Component | Auth |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `/` | LibraryPage | Protected |
|
||||||
|
| `/books/:id` | BookDetailPage | Protected |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
1. User types in search bar → 300ms debounce → `GET /api/books/?q=...`
|
||||||
|
2. User selects filter → `GET /api/books/?genre=...&author=...&reading_status=...`
|
||||||
|
3. User clicks result → navigate to `/books/:id`
|
||||||
|
4. BookDetailPage → `GET /api/books/{id}/`
|
||||||
|
|
||||||
|
## Mobile Optimizations
|
||||||
|
- Filters collapse into a toggleable panel on small screens
|
||||||
|
- Cards stack in single column on mobile
|
||||||
|
- Touch-friendly tap targets (min 44px)
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# US: Customizable Mobile Reading Experience
|
||||||
|
|
||||||
|
**Issue:** https://gitea-dev.codescripters.org/HermesFactory/cloud-reader/issues (TBD)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a full-screen reading view for ebooks with customizable typography, themes,
|
||||||
|
table of contents navigation, and orientation support. Mobile-first, responsive
|
||||||
|
design that adapts to any screen size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Specification
|
||||||
|
|
||||||
|
### New Models
|
||||||
|
|
||||||
|
#### `apps.books.models.Chapter`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------------|--------------------|--------------------------------|
|
||||||
|
| id | AutoField (PK) | |
|
||||||
|
| book | FK -> Book | related_name="chapters" |
|
||||||
|
| title | CharField(512) | Chapter title |
|
||||||
|
| number | PositiveIntegerField | Chapter ordering / TOC index |
|
||||||
|
| content | TextField | Chapter text/markdown content |
|
||||||
|
| created_at | DateTimeField | auto_now_add |
|
||||||
|
| updated_at | DateTimeField | auto_now |
|
||||||
|
|
||||||
|
**Constraints:** UniqueConstraint(book, chapter_number)
|
||||||
|
**Ordering:** [book, number]
|
||||||
|
**Index:** FK to book with db_index
|
||||||
|
|
||||||
|
#### `apps.books.models.ReadingProgress`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|------------------|--------------------|--------------------------------|
|
||||||
|
| id | AutoField (PK) | |
|
||||||
|
| user | FK -> User | related_name="reading_progress"|
|
||||||
|
| book | FK -> Book | related_name="reading_progress"|
|
||||||
|
| current_chapter | PositiveIntegerField | Last chapter number |
|
||||||
|
| current_position | PositiveIntegerField | Position within chapter (paragraph) |
|
||||||
|
| percentage | FloatField | 0.0 - 100.0 overall progress |
|
||||||
|
| updated_at | DateTimeField | auto_now |
|
||||||
|
|
||||||
|
**Constraints:** UniqueConstraint(user, book)
|
||||||
|
**Indexes:** (user, book) composite, (user) filter for list queries
|
||||||
|
|
||||||
|
#### `apps.reader.models.ReadingSettings`
|
||||||
|
|
||||||
|
New app `apps/reader/` for reading preferences, isolated from book data model.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------------------|--------------------|-------------------------------|
|
||||||
|
| id | AutoField (PK) | |
|
||||||
|
| user | OneToOneField -> User | related_name="reading_settings" |
|
||||||
|
| font_family | CharField(32) | "sans-serif", "serif", "monospace" |
|
||||||
|
| font_size | PositiveSmallIntegerField | 12-32, default 18 |
|
||||||
|
| line_height | FloatField | 1.2 - 2.0, default 1.6 |
|
||||||
|
| margin_width | PositiveSmallIntegerField | 8-48, default 16 (px) |
|
||||||
|
| background_color | CharField(7) | Hex color, default "#f5f0eb" |
|
||||||
|
| text_color | CharField(7) | Hex color, default "#1a1a1a" |
|
||||||
|
| brightness | PositiveSmallIntegerField | 0-100, default 100 |
|
||||||
|
| orientation_lock | CharField(16) | "auto", "portrait", "landscape" |
|
||||||
|
| theme | CharField(32) | "sepia", "dark", "light", "paper" |
|
||||||
|
| created_at | DateTimeField | auto_now_add |
|
||||||
|
| updated_at | DateTimeField | auto_now |
|
||||||
|
|
||||||
|
### New API Endpoints
|
||||||
|
|
||||||
|
All under `/api/` prefix, authenticated with JWT.
|
||||||
|
|
||||||
|
#### Reader Settings (`/api/reader/settings/`)
|
||||||
|
|
||||||
|
| Method | URL | Action |
|
||||||
|
|--------|------------------------------|---------------------------|
|
||||||
|
| GET | /api/reader/settings/ | Get current user settings |
|
||||||
|
| PUT | /api/reader/settings/ | Create/update settings |
|
||||||
|
| PATCH | /api/reader/settings/ | Partial update settings |
|
||||||
|
|
||||||
|
- Single-object endpoint (one settings record per user, auto-created on first GET)
|
||||||
|
- Validation: font_size 12-32, line_height 1.2-2.0, margin_width 8-48
|
||||||
|
|
||||||
|
#### Reading Progress (`/api/books/{id}/progress/`)
|
||||||
|
|
||||||
|
| Method | URL | Action |
|
||||||
|
|--------|----------------------------------------|------------------------------|
|
||||||
|
| GET | /api/books/{id}/progress/ | Get reading progress for book|
|
||||||
|
| PUT | /api/books/{id}/progress/ | Create/update reading progress|
|
||||||
|
|
||||||
|
- Nested under book detail
|
||||||
|
- Auto-creates progress record on first PUT
|
||||||
|
|
||||||
|
#### Chapters (`/api/books/{id}/chapters/`)
|
||||||
|
|
||||||
|
| Method | URL | Action |
|
||||||
|
|--------|----------------------------------------|------------------------------|
|
||||||
|
| GET | /api/books/{id}/chapters/ | List chapters for book (TOC) |
|
||||||
|
| GET | /api/books/{id}/chapters/{number}/ | Get specific chapter content |
|
||||||
|
|
||||||
|
- Ordering by `number`
|
||||||
|
- Used by frontend TOC sidebar and content loading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Specification
|
||||||
|
|
||||||
|
### New Pages
|
||||||
|
|
||||||
|
#### `/reader/:bookId` — ReadingPage
|
||||||
|
|
||||||
|
Full-screen reading view with:
|
||||||
|
- Chapter content display (left/right swiping or scroll)
|
||||||
|
- Bottom toolbar: TOC toggle, Settings toggle, Progress indicator
|
||||||
|
- Top bar: Back button, Book title, Chapter title
|
||||||
|
- Swipe/tap/page navigation between chapters
|
||||||
|
|
||||||
|
### New Components
|
||||||
|
|
||||||
|
#### `ReaderToolbar`
|
||||||
|
- Fixed bottom toolbar
|
||||||
|
- TOC button (opens TOC drawer)
|
||||||
|
- Settings/theme button (opens settings panel)
|
||||||
|
- Progress bar showing overall reading progress
|
||||||
|
|
||||||
|
#### `TableOfContents`
|
||||||
|
- Slide-in drawer from left
|
||||||
|
- Lists all chapters with current chapter highlighted
|
||||||
|
- Tap on chapter to navigate
|
||||||
|
- Shows reading progress per chapter
|
||||||
|
|
||||||
|
#### `ReadingSettingsPanel`
|
||||||
|
- Slide-in drawer from right (or bottom sheet on mobile)
|
||||||
|
- Controls:
|
||||||
|
- Theme presets: Sepia, Dark, Light, Paper
|
||||||
|
- Font family: Sans-serif, Serif, Monospace
|
||||||
|
- Font size slider (12-32)
|
||||||
|
- Line height slider (1.2-2.0)
|
||||||
|
- Margin/padding control
|
||||||
|
- Orientation lock toggle (Auto / Portrait / Landscape)
|
||||||
|
- All changes persist immediately via API
|
||||||
|
- LocalStorage fallback when offline
|
||||||
|
|
||||||
|
### New Hooks
|
||||||
|
|
||||||
|
#### `useReadingSettings(bookId)`
|
||||||
|
- Fetches user reading settings from API
|
||||||
|
- Returns current settings + update function
|
||||||
|
- Applies CSS custom properties to document root
|
||||||
|
- Falls back to defaults if API unavailable
|
||||||
|
|
||||||
|
#### `useChapters(bookId)`
|
||||||
|
- Fetches chapter list for TOC
|
||||||
|
- Returns chapters array, current chapter, navigate function
|
||||||
|
- Prefetches next/prev chapter content
|
||||||
|
|
||||||
|
#### `useReadingProgress(bookId)`
|
||||||
|
- Fetches/updates reading progress
|
||||||
|
- Auto-saves position on chapter change and periodic interval
|
||||||
|
|
||||||
|
### New Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Chapter {
|
||||||
|
id: number;
|
||||||
|
book: number;
|
||||||
|
title: string;
|
||||||
|
number: number;
|
||||||
|
content?: string; // Only present when fetching individual chapter
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChapterSummary {
|
||||||
|
id: number;
|
||||||
|
book: number;
|
||||||
|
title: string;
|
||||||
|
number: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadingSettings {
|
||||||
|
font_family: "sans-serif" | "serif" | "monospace";
|
||||||
|
font_size: number;
|
||||||
|
line_height: number;
|
||||||
|
margin_width: number;
|
||||||
|
background_color: string;
|
||||||
|
text_color: string;
|
||||||
|
brightness: number;
|
||||||
|
orientation_lock: "auto" | "portrait" | "landscape";
|
||||||
|
theme: "sepia" | "dark" | "light" | "paper";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadingProgress {
|
||||||
|
current_chapter: number;
|
||||||
|
current_position: number;
|
||||||
|
percentage: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS / Theming
|
||||||
|
|
||||||
|
Reading view uses CSS custom properties driven by reading settings:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--reader-bg: var(--bg-color, #f5f0eb);
|
||||||
|
--reader-text: var(--text-color, #1a1a1a);
|
||||||
|
--reader-font-family: var(--font-family, "Georgia", serif);
|
||||||
|
--reader-font-size: var(--font-size, 18px);
|
||||||
|
--reader-line-height: var(--line-height, 1.6);
|
||||||
|
--reader-margin: var(--margin-width, 16px);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three theme presets:
|
||||||
|
- **Sepia**: `bg:#f5f0eb`, `text:#1a1a1a` — warm, easy on eyes
|
||||||
|
- **Dark**: `bg:#1a1a2e`, `text:#e0e0e0` — for low-light reading
|
||||||
|
- **Light**: `bg:#ffffff`, `text:#1a1a1a` — crisp and clean
|
||||||
|
- **Paper**: `bg:#e8e0d4`, `text:#2c2c2c` — book-like feel
|
||||||
|
|
||||||
|
### Orientation Support
|
||||||
|
|
||||||
|
- CSS `@media (orientation: portrait)` and `@media (orientation: landscape)` breakpoints
|
||||||
|
- Reading settings panel includes orientation lock toggle
|
||||||
|
- On mobile, landscape mode expands content horizontally with wider margins
|
||||||
|
- Portrait mode stacks controls vertically for thumb-reachable UI
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
Add to App.tsx:
|
||||||
|
```
|
||||||
|
/ → LibraryPage
|
||||||
|
/books/:bookId → BookDetailPage
|
||||||
|
/reader/:bookId → ReadingPage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Backend models + migrations (Chapter, ReadingProgress, ReadingSettings)
|
||||||
|
2. Backend serializers + views + URLs
|
||||||
|
3. Frontend types + API client
|
||||||
|
4. Frontend hooks (useReadingSettings, useChapters, useReadingProgress)
|
||||||
|
5. Frontend components (ReadingSettingsPanel, TableOfContents, ReaderToolbar)
|
||||||
|
6. Frontend page (ReadingPage)
|
||||||
|
7. Routing updates
|
||||||
|
8. CSS / theming
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 011 — Web reader (react-reader)
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-03
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Replace the custom HTML chapter reader with [react-reader](https://github.com/gerhardsletten/react-reader) (epub.js) for paginated EPUB reading in the web app. Keep the existing toolbar and settings panel chrome. Block PDFs from the in-browser reader.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Library (EPUB only) → /read/:id → ReadingPage
|
||||||
|
→ GET /api/books/ebooks/{id}/ (metadata, format guard)
|
||||||
|
→ GET /api/books/ebooks/{id}/file/ (authenticated EPUB blob)
|
||||||
|
→ ReactReader (blob URL + CFI location)
|
||||||
|
→ PATCH /api/books/ebooks/{id}/progress/ (epub_location + percentage)
|
||||||
|
```
|
||||||
|
|
||||||
|
EPUB files are fetched with JWT via the API, converted to a blob URL client-side, and passed to react-reader. This avoids unauthenticated `/media/` URLs and CORS issues in dev.
|
||||||
|
|
||||||
|
## Backend changes
|
||||||
|
|
||||||
|
### `GET /api/books/ebooks/{id}/file/`
|
||||||
|
|
||||||
|
- Authenticated, owner-only
|
||||||
|
- Returns `FileResponse` with `Content-Type: application/epub+zip`
|
||||||
|
- Returns `400` if format is not `epub`
|
||||||
|
|
||||||
|
### `ReadingProgress.epub_location`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `epub_location` | CharField(2048) | EPUB CFI string for resume position |
|
||||||
|
|
||||||
|
Exposed on `GET/PATCH /api/books/ebooks/{id}/progress/` and in `EBookDetail.progress`.
|
||||||
|
|
||||||
|
`current_position` stores whole-book percentage (0–100) derived from epub.js locations.
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `pages/ReadingPage.tsx` | `ReactReader` replaces chapter HTML rendering |
|
||||||
|
| `hooks/useEpubReader.ts` | Blob load, CFI state, debounced progress save |
|
||||||
|
| `utils/epubRendition.ts` | Theme/font application via `getRendition` |
|
||||||
|
| `api/books.ts` | `getEpubFile(id)` |
|
||||||
|
| `pages/Library.tsx` | Block PDF open with notice |
|
||||||
|
| `App.tsx` | Single route `/read/:id`; `/reader/:id` redirects |
|
||||||
|
|
||||||
|
### Removed (web-only)
|
||||||
|
|
||||||
|
- `useChapters.ts`, `TableOfContents.tsx`, `Reader.tsx`, `useReadingProgress.ts`
|
||||||
|
|
||||||
|
Backend `/toc/` and `/content/` endpoints remain for mobile/API consumers.
|
||||||
|
|
||||||
|
## EPUB-only policy
|
||||||
|
|
||||||
|
- Library: clicking a PDF shows a dismissible notice; no navigation to reader
|
||||||
|
- ReadingPage: deep-link guard if `format !== 'epub'`
|
||||||
|
- Upload still accepts PDF for storage; web reader is EPUB-only
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Frontend: `react-reader` (^2.0.15)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Open an EPUB from library → paginated reading, swipe/tap page turns, built-in TOC
|
||||||
|
2. Close and reopen → resumes at saved CFI
|
||||||
|
3. Change theme/font in settings → applies inside epub iframe
|
||||||
|
4. Click a PDF in library → notice shown, reader not opened
|
||||||
|
5. `GET /api/books/ebooks/{id}/file/` without auth → 401
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 012 — Library book context menu
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-03
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add a custom right-click context menu on each book card in the library with **Sync metadata** (Open Library refresh) and **Remove** (delete from library). Show toast notifications for success, warning, and error outcomes.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Library book card (contextmenu)
|
||||||
|
→ BookContextMenu
|
||||||
|
→ Sync metadata: POST /api/books/ebooks/{id}/enrich-metadata/
|
||||||
|
→ Remove: confirm dialog → DELETE /api/books/ebooks/{id}/
|
||||||
|
→ ToastProvider (app-wide) → success | warning | error toasts
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend endpoints already exist; no API changes required.
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `api/books.ts` | `enrichEBookMetadata(id)` → `POST .../enrich-metadata/` |
|
||||||
|
| `types/book.ts` | `OpenLibraryMetadata` with `match_status` |
|
||||||
|
| `hooks/useToast.tsx` | `ToastProvider`, `showToast({ message, variant })`, auto-dismiss ~4s |
|
||||||
|
| `components/ToastContainer.tsx` | Fixed bottom-right toast stack |
|
||||||
|
| `components/BookContextMenu.tsx` | Positioned menu; sync + remove actions |
|
||||||
|
| `pages/Library.tsx` | `onContextMenu` on cards; local state updates |
|
||||||
|
| `App.tsx` | Wrap routes with `ToastProvider` |
|
||||||
|
|
||||||
|
## Context menu behavior
|
||||||
|
|
||||||
|
- Opens on right-click (`contextmenu`); browser default menu suppressed
|
||||||
|
- Position clamped to viewport; closes on outside click, Escape, or scroll
|
||||||
|
- **Sync metadata:** updates card title/author/cover from response; toast by `metadata.match_status`:
|
||||||
|
- `matched` → success
|
||||||
|
- `not_found` → warning
|
||||||
|
- other → neutral success
|
||||||
|
- **Remove:** `window.confirm` before delete; removes card from list on success
|
||||||
|
- Menu clicks do not trigger card navigation to reader
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None (no new npm packages).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Right-click a book → menu appears at cursor
|
||||||
|
2. Sync metadata on a matched book → cover/title may update, success toast
|
||||||
|
3. Sync on unmatched book → warning toast, no crash
|
||||||
|
4. Remove → confirm → book disappears, success toast
|
||||||
|
5. Cancel remove → book stays, menu closes
|
||||||
|
6. Left-click still opens reader (EPUB) or PDF notice
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 013 — Frontend i18n (react-i18n-lite)
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-04
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Internationalize the web UI in English (`en-US`) and Spanish (`es-ES`) using [react-i18n-lite](https://www.npmjs.com/package/react-i18n-lite).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
App.tsx
|
||||||
|
└── I18nProvider (TranslationContainer)
|
||||||
|
├── resolveDefaultLanguage() — localStorage → navigator → en-US
|
||||||
|
├── LanguagePersistence — sync setLanguage ↔ localStorage, html[lang]
|
||||||
|
└── AuthProvider → ToastProvider → routes
|
||||||
|
```
|
||||||
|
|
||||||
|
Components call `useTranslation()` and `t('namespace.key', { interpolation })`.
|
||||||
|
|
||||||
|
## Locale files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `frontend/src/locales/en-US.ts` | English dictionary |
|
||||||
|
| `frontend/src/locales/es-ES.ts` | Spanish dictionary |
|
||||||
|
| `frontend/src/locales/index.ts` | `locales` map, `SupportedLanguage`, helpers |
|
||||||
|
|
||||||
|
Key namespaces: `common`, `auth`, `library`, `contextMenu`, `toast`, `settings`, `reader`, `addBook`, `bookDetail`, `search`, `annotations`.
|
||||||
|
|
||||||
|
## Language selection
|
||||||
|
|
||||||
|
- **Settings page** — UI Language dropdown (English / Español)
|
||||||
|
- **Persistence** — `localStorage` key `cloud-reader.locale`
|
||||||
|
- **Default** — saved preference, else `navigator.language` (`es*` → `es-ES`), else `en-US`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**Translated:** All user-facing chrome (library, auth, reader toolbar/settings/TOC, add book, book detail, annotations, context menu, toasts).
|
||||||
|
|
||||||
|
**Not translated:** EPUB body content, API error bodies, backend `reading_status_display`, Open Library metadata fields.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `react-i18n-lite` (^1.0.10)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Open app — labels match browser or saved language
|
||||||
|
2. Settings → switch to Español — library/auth update without reload
|
||||||
|
3. Refresh — language persists
|
||||||
|
4. Context menu and toasts show translated strings
|
||||||
|
5. `yarn build` succeeds
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 014 — Library reading progress badges
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-04
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Show accurate reading state on library book cards using saved EPUB progress instead of always displaying "Want to Read" (`Quiero leer`).
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
- `GET /api/books/ebooks/` returns `progress` (0–100) from `ReadingProgress.current_position` via `EBookListSerializer.get_progress`.
|
||||||
|
- Progress is updated by the web reader (`useEpubReader` → `PATCH .../progress/`).
|
||||||
|
|
||||||
|
## Display rules
|
||||||
|
|
||||||
|
| Progress | `opened` | Badge | Cover extra |
|
||||||
|
|----------|----------|-------|-------------|
|
||||||
|
| `null` or `0` | false | Want to Read | — |
|
||||||
|
| `0` | true | Opened | — |
|
||||||
|
| `1–98` | — | Reading · N% | Green progress bar on cover |
|
||||||
|
| `≥ 99` | — | Finished | — |
|
||||||
|
|
||||||
|
**Opened** is set when the reader first displays (CFI saved, 0% progress). Turning pages moves to **Reading**.
|
||||||
|
|
||||||
|
Threshold constant: `FINISHED_PROGRESS_THRESHOLD = 99` in `frontend/src/utils/libraryStatus.ts`.
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `utils/libraryStatus.ts` | `deriveReadingStatus`, `normalizeProgressPercent` |
|
||||||
|
| `pages/Library.tsx` | Map API progress → status; badge labels; client-side status filter |
|
||||||
|
| `locales/en-US.ts`, `es-ES.ts` | `library.readingStatus.readingWithProgress` |
|
||||||
|
| `pages/Library.module.css` | Cover progress bar styles |
|
||||||
|
|
||||||
|
## Status filter
|
||||||
|
|
||||||
|
The reading-status filter in the library panel now filters client-side by derived status (`want_to_read`, `reading`, `finished`).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. New upload → **Want to Read** / **Quiero leer**
|
||||||
|
2. Partial read → **Reading · N%** + progress bar
|
||||||
|
3. Near end (≥ 99%) → **Finished** / **Terminado**
|
||||||
|
4. Filters by Leyendo / Terminado work as expected
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 015 — EPUB bookmarks and notes (physical-book UX)
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-04
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Let users select passage text while reading an EPUB, save a bookmark (with optional thought), list markers in chapter order, and jump back to the exact location. Global view groups markers by book in a Reddit-style thread layout.
|
||||||
|
|
||||||
|
## Fix: AnnotationsProvider
|
||||||
|
|
||||||
|
`AnnotationsProvider` wraps all routes in [`App.tsx`](frontend/src/App.tsx) so `/bookmarks-notes` and the reader can use `useAnnotations()`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`Bookmark` (annotations app) references **`books.EBook`**, not catalog `Book`:
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `ebook` | FK to uploaded ebook |
|
||||||
|
| `epub_cfi` | EPUB CFI anchor |
|
||||||
|
| `chapter_index` | Spine index for sort order |
|
||||||
|
| `chapter_title` | Display label |
|
||||||
|
| `location_text` | Selected passage quote |
|
||||||
|
| `content` | Optional user thought (empty = bookmark only) |
|
||||||
|
| `page` | Legacy display field (`chapter_index + 1`) |
|
||||||
|
|
||||||
|
Unique: `(user, ebook, epub_cfi)`.
|
||||||
|
|
||||||
|
Default API ordering: `chapter_index`, `epub_cfi`.
|
||||||
|
|
||||||
|
Legacy `Note` model remains for old API; new UX uses `Bookmark.content` only.
|
||||||
|
|
||||||
|
## Reader flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant EpubView
|
||||||
|
participant Popover
|
||||||
|
participant API
|
||||||
|
User->>EpubView: Select text
|
||||||
|
EpubView->>Popover: Show near selection
|
||||||
|
User->>Popover: Save optional thought
|
||||||
|
Popover->>API: POST /annotations/bookmarks/
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Text selection via epub.js `selected` event and content `mouseup` hook.
|
||||||
|
2. [`SelectionPopover`](frontend/src/components/reader/SelectionPopover.tsx) — floating UI, optional textarea.
|
||||||
|
3. Toolbar bookmark icon opens [`BookMarkersPanel`](frontend/src/components/reader/BookMarkersPanel.tsx) (current ebook only).
|
||||||
|
4. “Go to passage” sets reader `location` to stored CFI (`/read/:id` with router state).
|
||||||
|
|
||||||
|
## Global page (`/bookmarks-notes`)
|
||||||
|
|
||||||
|
[`MarkerThreadsView`](frontend/src/components/annotations/MarkerThreadsView.tsx):
|
||||||
|
|
||||||
|
- Groups markers by ebook (collapsible book rows).
|
||||||
|
- Within each book: chapter order, passage as blockquote, thought indented below (Reddit-style).
|
||||||
|
- Optional filter: `/bookmarks-notes/:ebookId`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /api/annotations/bookmarks/?ebook={id}`
|
||||||
|
- `POST /api/annotations/bookmarks/` — body: `ebook`, `epub_cfi`, `chapter_index`, `chapter_title`, `location_text`, `content`
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
New keys under `annotations.*` (EN/ES): `saveMarker`, `thoughtPlaceholder`, `bookmarkOnly`, `selectTextHint`, `goToPassage`, `inBookPanel`, `markerCount`, etc.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `/bookmarks-notes` loads without provider error.
|
||||||
|
2. Select text in reader → popover → save with/without thought.
|
||||||
|
3. Markers appear in reader panel and global page under correct book, in chapter order.
|
||||||
|
4. “Go to passage” opens the correct location.
|
||||||
|
|
||||||
|
See also [016 — Bookmark reading anchor](016-bookmark-reading-anchor.md) for preserving reading position while peeking at bookmarks.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- PDF selection
|
||||||
|
- Multiple replies per passage
|
||||||
|
- Migrating legacy `Note` rows into `Bookmark`
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 016 — Bookmark reading anchor
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Created:** 2026-06-04
|
||||||
|
**See also:** [015 — EPUB bookmarks and notes](015-epub-bookmarks-notes.md)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Let users jump to a bookmark to review a passage without overwriting their true reading position. While peeking, show a high-visibility control to return to where they were reading.
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
| Term | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| **Reading anchor** | EPUB CFI (+ optional %) captured immediately before a bookmark peek |
|
||||||
|
| **Peek mode** | Temporary view at a bookmark location; server `ReadingProgress` is not updated |
|
||||||
|
| **Resume** | Jump back to the reading anchor and re-enable progress persistence |
|
||||||
|
|
||||||
|
## Problem (before)
|
||||||
|
|
||||||
|
“Go to passage” called `jumpToCfi`, which triggered `locationChanged` and debounced `PATCH /books/ebooks/{id}/progress/`, replacing `epub_location` and `current_position` with the bookmark. Library progress and the next reading session started at the bookmark instead of the real position.
|
||||||
|
|
||||||
|
## Triggers (enter peek mode)
|
||||||
|
|
||||||
|
- In-reader: **Go to passage** in [`BookMarkersPanel`](../frontend/src/components/reader/BookMarkersPanel.tsx)
|
||||||
|
- Global: **Go to passage** on [`/bookmarks-notes`](../frontend/src/components/annotations/BookmarksNotesPage.tsx) → `/read/:id` with `state.epubLocation` (bookmark CFI)
|
||||||
|
|
||||||
|
## Non-triggers
|
||||||
|
|
||||||
|
- Table of contents navigation
|
||||||
|
- Prev / next page buttons
|
||||||
|
- Creating a new bookmark from text selection
|
||||||
|
- Opening the book normally from the library (no `epubLocation` in router state)
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
### Resume control
|
||||||
|
|
||||||
|
- Component: [`ResumeReadingButton`](../frontend/src/components/reader/ResumeReadingButton.tsx)
|
||||||
|
- Visible only when `isBookmarkPeekActive && readingAnchor != null`
|
||||||
|
- Position: right edge, **above** the next-page chevron (`.reader-page-nav--next`)
|
||||||
|
- Style: warm accent (`#ea580c` / `#f97316`), white label/icon; distinct from muted gray nav buttons
|
||||||
|
- Action: `resumeReadingAnchor()` — hides control, returns to anchor CFI
|
||||||
|
- i18n: `reader.resumeReading`, `reader.resumeReadingAria`
|
||||||
|
|
||||||
|
### Anchor policy
|
||||||
|
|
||||||
|
- First anchor is captured when peek starts; **additional “Go to passage” clicks while peeking do not replace the anchor** until the user resumes or leaves the reader.
|
||||||
|
|
||||||
|
## Progress rules
|
||||||
|
|
||||||
|
| Mode | `PATCH .../progress/` |
|
||||||
|
|------|------------------------|
|
||||||
|
| Normal reading | Yes (debounced on `locationChanged`, sync after locations ready) |
|
||||||
|
| Bookmark peek | **No** — `flushProgress` / `scheduleProgress` no-op |
|
||||||
|
| After resume | Yes — flush anchor CFI and percentage once |
|
||||||
|
|
||||||
|
### Load from bookmarks page
|
||||||
|
|
||||||
|
1. Fetch saved `ReadingProgress` from API.
|
||||||
|
2. If `state.epubLocation` is set and saved `epub_location` exists → store saved location as **anchor**, set peek mode, open at bookmark CFI.
|
||||||
|
3. Do not persist bookmark location as progress during peek.
|
||||||
|
|
||||||
|
### In-reader peek
|
||||||
|
|
||||||
|
1. Capture current `location` (CFI) as anchor (if valid).
|
||||||
|
2. Enter peek mode, jump to bookmark CFI.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
No backend changes in v1. Anchor is session-only in [`useEpubReader`](../frontend/src/hooks/useEpubReader.ts).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Read to ~30%, open markers, **Go to passage** on an early bookmark → jumps; after debounce, library/API progress still reflects ~30% (not bookmark).
|
||||||
|
2. Orange **Back to reading** appears above the next chevron only during peek.
|
||||||
|
3. Tap resume → returns to ~30%; button hides; progress saves resume.
|
||||||
|
4. From `/bookmarks-notes`, **Go to passage** → peek + resume using saved progress as anchor.
|
||||||
|
5. TOC / prev / next do not show the resume button.
|
||||||
|
6. EN/ES strings present.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- PDF reader
|
||||||
|
- Multiple anchor history stack
|
||||||
|
- Backend `resume_epub_location` field
|
||||||
|
- Peek mode for TOC jumps
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user