Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8bd1dca14 |
@@ -1,10 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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
|
||||||
+45
-37
@@ -1,40 +1,48 @@
|
|||||||
node_modules
|
# Python
|
||||||
dist
|
__pycache__/
|
||||||
frontend/dist
|
*.py[cod]
|
||||||
frontend/dist/assets
|
*.egg-info/
|
||||||
frontend/dist/assets/index.html
|
dist/
|
||||||
frontend/dist/assets/index.html.gz
|
build/
|
||||||
frontend/dist/assets/index.html.br
|
.eggs/
|
||||||
frontend/dist/assets/index.html.brotli
|
*.egg
|
||||||
frontend/dist/assets/index.html.gzip
|
.env
|
||||||
frontend/dist/assets/index.html.br
|
venv/
|
||||||
frontend/dist/assets/index.html.brotli
|
.venv/
|
||||||
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
|
|
||||||
*.env
|
# Node
|
||||||
*.DS_Store
|
node_modules/
|
||||||
*.vscode
|
.pnp
|
||||||
*.idea
|
.pnp.js
|
||||||
|
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
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 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,93 +1,195 @@
|
|||||||
# Cloud Reader
|
# Cloud Reader
|
||||||
|
|
||||||
A full-stack e-book reader application with cross-device sync. Upload EPUB/PDF files, track reading progress, bookmark passages, take notes, and customize your reading experience.
|
A modern eBook reader with web and mobile clients, powered by Django REST Framework.
|
||||||
|
|
||||||
## Architecture
|
## Monorepo Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
cloud-reader/
|
cloud-reader/
|
||||||
├── backend/ # Django REST API (canonical backend)
|
├── backend/ # Django API server (Python 3.12 + DRF)
|
||||||
│ ├── config/ # Django project settings
|
│ ├── config/ # Django project settings
|
||||||
│ ├── apps/
|
│ ├── apps/ # Django applications
|
||||||
│ │ ├── users/ # User auth (JWT)
|
│ │ ├── accounts/ # User authentication & profiles
|
||||||
│ │ ├── books/ # Books, e-books, reading progress, settings
|
│ │ ├── documents/ # Document management & uploads
|
||||||
│ │ └── annotations/ # Bookmarks and notes
|
│ │ ├── collections/# Document collections
|
||||||
│ ├── manage.py
|
│ │ └── reading/ # Bookmarks, highlights, reading progress
|
||||||
│ └── requirements.txt
|
│ ├── requirements/ # pip dependency files
|
||||||
├── frontend/ # React + Vite + TypeScript (web frontend)
|
│ ├── Dockerfile
|
||||||
|
│ └── manage.py
|
||||||
|
├── frontend/ # React web app (TypeScript + Vite)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── api/ # API client (axios with JWT refresh)
|
│ │ ├── pages/ # Route pages (lazy-loaded)
|
||||||
│ │ ├── components/ # Reusable components
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
│ │ ├── context/ # Auth and annotations context
|
│ │ ├── services/ # API client & auth service
|
||||||
│ │ ├── hooks/ # Custom hooks
|
│ │ ├── types/ # Frontend-specific types
|
||||||
│ │ ├── pages/ # Route pages (Library, Reader, AddBook, Auth, Settings)
|
│ │ └── styles/ # Global CSS
|
||||||
│ │ └── types/ # TypeScript type definitions
|
│ ├── Dockerfile
|
||||||
│ └── package.json
|
│ └── vite.config.ts
|
||||||
├── mobile/ # Expo React Native app (mobile frontend)
|
├── mobile/ # Expo/React Native mobile app
|
||||||
│ ├── src/
|
│ ├── app/ # Expo Router pages
|
||||||
│ │ ├── api/ # API client (axios with JWT refresh via AsyncStorage)
|
│ ├── src/ # Mobile source code
|
||||||
│ │ ├── components/ # Reusable UI components
|
│ ├── app.json
|
||||||
│ │ ├── context/ # Auth context
|
│ └── Dockerfile
|
||||||
│ │ ├── hooks/ # Custom hooks
|
├── shared/ # Shared TypeScript types & utilities
|
||||||
│ │ ├── navigation/ # React Navigation (Auth stack + Main tabs)
|
|
||||||
│ │ ├── screens/ # Screen-level components (Login, Library, etc.)
|
|
||||||
│ │ └── types/ # Mobile-specific types
|
|
||||||
│ ├── App.tsx
|
|
||||||
│ └── app.json
|
|
||||||
├── packages/
|
|
||||||
│ └── shared/ # @cloud-reader/shared — domain types & utilities
|
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── types.ts # Shared domain types (Book, User, Bookmark, Note, etc.)
|
│ └── index.ts # API response types, constants
|
||||||
│ └── utils.ts # Date formatting, validation, API endpoint constants
|
├── .github/workflows/ # CI/CD pipelines
|
||||||
├── package.json # Root — yarn workspaces config
|
└── package.json # Yarn workspace root
|
||||||
└── docker-compose.yml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Prerequisites
|
||||||
|
|
||||||
### Docker (recommended)
|
- **Python** 3.12+
|
||||||
```bash
|
- **Node.js** 20 LTS
|
||||||
cp env.example .env
|
- **Yarn** 4.x
|
||||||
```
|
- **PostgreSQL** 16
|
||||||
```bash
|
- **Expo CLI** (for mobile development)
|
||||||
docker compose up --build
|
|
||||||
```
|
---
|
||||||
- **Frontend:** http://localhost:5173
|
|
||||||
- **Backend API:** http://localhost:8000/api/
|
## Backend Setup
|
||||||
|
|
||||||
### 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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (standalone)
|
The API will be available at `http://localhost:8000/`. Browse the API at `http://localhost:8000/api/schema/swagger-ui/`.
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd backend
|
||||||
yarn install
|
pytest
|
||||||
yarn dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mobile (Expo)
|
---
|
||||||
|
|
||||||
|
## Frontend Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From monorepo root — installs all workspaces including mobile
|
# From monorepo root
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# 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/`.
|
||||||
|
|
||||||
|
### Frontend Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn frontend:build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From monorepo root
|
||||||
yarn install
|
yarn install
|
||||||
|
|
||||||
# Start Expo dev server
|
# Start Expo dev server
|
||||||
yarn workspace @cloud-reader/mobile start
|
yarn mobile:start
|
||||||
|
|
||||||
# Or cd into mobile and run directly
|
# Run on Android
|
||||||
cd mobile
|
yarn mobile:android
|
||||||
npx expo start
|
|
||||||
|
# Run on iOS (macOS only)
|
||||||
|
yarn mobile:ios
|
||||||
```
|
```
|
||||||
|
|
||||||
> 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).
|
> 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.
|
||||||
|
|
||||||
## Migration Notes
|
---
|
||||||
Consolidated from duplicate `api/` + `web/` into single `backend/` + `frontend/` canonical structure.
|
|
||||||
- `backend/` kept as canonical; `api/` features (e-book uploads, reading progress, reading settings) merged in.
|
## Shared Package
|
||||||
- `frontend/` kept as canonical; `web/` pages (Library, Reader, AddBook, Auth, Settings) merged in.
|
|
||||||
- `api/` and `web/` directories removed.
|
The `shared/` package contains TypeScript types and constants used by both the frontend and mobile apps.
|
||||||
- `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)
|
||||||
|
|||||||
+11
-12
@@ -1,16 +1,15 @@
|
|||||||
# Backend environment (example – never commit real secrets)
|
# Cloud Reader Backend — Environment Variables
|
||||||
DJANGO_SECRET_KEY=django-insecure-change-me-in-production
|
# Copy to .env and fill in your values.
|
||||||
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=postgres
|
DB_USER=cloud_reader
|
||||||
DB_PASSWORD=postgres
|
DB_PASSWORD=cloud_reader
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
# Open Library metadata enrichment (optional)
|
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
OPENLIBRARY_ENABLED=true
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
OPENLIBRARY_PREFERRED_LANG=es
|
|
||||||
OPENLIBRARY_FALLBACK_LANG=en
|
|
||||||
OPENLIBRARY_TIMEOUT_SECONDS=10
|
|
||||||
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS=15
|
|
||||||
OPENLIBRARY_USER_AGENT=CloudReader/1.0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
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
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
COPY requirements/production.txt /app/requirements/
|
||||||
libpq-dev gcc && \
|
RUN pip install --no-cache-dir -r requirements/production.txt
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY requirements.txt ./
|
COPY . /app
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY . ./
|
RUN python manage.py collectstatic --noinput
|
||||||
|
|
||||||
RUN mkdir -p media
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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)
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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",)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AnnotationsConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "apps.annotations"
|
|
||||||
label = "annotations"
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# 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')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
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}"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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)),
|
|
||||||
]
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class BooksConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "apps.books"
|
|
||||||
label = "books"
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# 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
@@ -1,105 +0,0 @@
|
|||||||
# 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')},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
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())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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()]
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
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),
|
|
||||||
)
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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)),
|
|
||||||
]
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.db.models import QuerySet, Q
|
|
||||||
from django.http import FileResponse
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
from rest_framework import parsers, permissions, status, viewsets
|
|
||||||
from rest_framework.decorators import action, api_view, permission_classes
|
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress
|
|
||||||
from apps.books.serializers import (
|
|
||||||
BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer,
|
|
||||||
BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer,
|
|
||||||
EBookListSerializer, EBookTocSerializer, EBookUploadSerializer,
|
|
||||||
ReadingProgressSerializer, StorageSummarySerializer,
|
|
||||||
)
|
|
||||||
from apps.reader.models import ReadingSettings
|
|
||||||
from apps.books.services.ebook_metadata import subjects_from_ebook
|
|
||||||
from apps.books.services.metadata import enrich_ebook_metadata
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class BookViewSet(viewsets.ModelViewSet):
|
|
||||||
queryset = Book.objects.all()
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
|
||||||
filterset_fields = ["author", "genre", "reading_status"]
|
|
||||||
search_fields = ["title", "author", "genre"]
|
|
||||||
ordering_fields = ["title", "author", "genre", "created_at"]
|
|
||||||
ordering = ["title"]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action == "retrieve":
|
|
||||||
return BookDetailSerializer
|
|
||||||
if self.action == "list":
|
|
||||||
return BookListSerializer
|
|
||||||
return BookSerializer
|
|
||||||
|
|
||||||
def get_queryset(self) -> QuerySet[Book]:
|
|
||||||
qs = super().get_queryset()
|
|
||||||
query = self.request.query_params.get("q", "").strip()
|
|
||||||
if query:
|
|
||||||
qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query))
|
|
||||||
return qs
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
|
|
||||||
def genres(self, request: Request) -> Response:
|
|
||||||
genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre")
|
|
||||||
return Response([g for g in genre_list if g])
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], permission_classes=[AllowAny])
|
|
||||||
def authors(self, request: Request) -> Response:
|
|
||||||
author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author")
|
|
||||||
return Response([a for a in author_list if a])
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def storage(self, request: Request) -> Response:
|
|
||||||
"""Return storage usage summary for the current user."""
|
|
||||||
download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook")
|
|
||||||
total_size = sum(r.file_size for r in download_records)
|
|
||||||
ebook_list = [
|
|
||||||
{"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size}
|
|
||||||
for r in download_records
|
|
||||||
]
|
|
||||||
serializer = StorageSummarySerializer(data={
|
|
||||||
"total_downloads": download_records.count(),
|
|
||||||
"total_size_bytes": total_size,
|
|
||||||
"ebooks": ebook_list,
|
|
||||||
})
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
|
|
||||||
class IsEBookOwner(permissions.BasePermission):
|
|
||||||
def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool:
|
|
||||||
return obj.user == request.user
|
|
||||||
|
|
||||||
|
|
||||||
class EBookViewSet(viewsets.ModelViewSet):
|
|
||||||
parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser]
|
|
||||||
permission_classes = [IsAuthenticated, IsEBookOwner]
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action == "create":
|
|
||||||
return EBookUploadSerializer
|
|
||||||
if self.action in ("list",):
|
|
||||||
return EBookListSerializer
|
|
||||||
if self.action in ("toc",):
|
|
||||||
return BookChapterSerializer
|
|
||||||
return EBookDetailSerializer
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
qs = EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user")
|
|
||||||
query = self.request.query_params.get("q", "").strip()
|
|
||||||
if query and self.action == "list":
|
|
||||||
qs = qs.filter(
|
|
||||||
Q(title__icontains=query) | Q(author__icontains=query),
|
|
||||||
)
|
|
||||||
return qs
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def genres(self, request: Request) -> Response:
|
|
||||||
genre_set: set[str] = set()
|
|
||||||
for ebook in self.get_queryset():
|
|
||||||
genre_set.update(subjects_from_ebook(ebook))
|
|
||||||
return Response(sorted(genre_set))
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def authors(self, request: Request) -> Response:
|
|
||||||
author_list = (
|
|
||||||
self.get_queryset()
|
|
||||||
.values_list("author", flat=True)
|
|
||||||
.distinct()
|
|
||||||
.order_by("author")
|
|
||||||
)
|
|
||||||
return Response([a for a in author_list if a])
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get", "patch"])
|
|
||||||
def progress(self, request: Request, pk: int | None = None) -> Response:
|
|
||||||
ebook = self.get_object()
|
|
||||||
progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook)
|
|
||||||
if request.method == "GET":
|
|
||||||
serializer = ReadingProgressSerializer(progress_obj)
|
|
||||||
return Response(serializer.data)
|
|
||||||
serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"], url_path="enrich-metadata")
|
|
||||||
def enrich_metadata(self, request: Request, pk: int | None = None) -> Response:
|
|
||||||
"""Re-fetch Open Library metadata and cover for this ebook."""
|
|
||||||
ebook = self.get_object()
|
|
||||||
try:
|
|
||||||
enrich_ebook_metadata(ebook)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Manual metadata enrichment failed for ebook %s", ebook.id)
|
|
||||||
return Response(
|
|
||||||
{"error": "Metadata enrichment failed."},
|
|
||||||
status=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
)
|
|
||||||
ebook.refresh_from_db()
|
|
||||||
serializer = EBookDetailSerializer(ebook, context={"request": request})
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def process(self, request: Request, pk: int | None = None) -> Response:
|
|
||||||
"""Trigger e-book processing: metadata extraction, TOC building, page counting."""
|
|
||||||
ebook = self.get_object()
|
|
||||||
if not ebook.file:
|
|
||||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from apps.books.services.process_ebook import apply_processing_to_ebook
|
|
||||||
|
|
||||||
result = apply_processing_to_ebook(ebook)
|
|
||||||
return Response(result)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Failed to process ebook %s", ebook.id)
|
|
||||||
return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
|
||||||
def file(self, request: Request, pk: int | None = None) -> FileResponse | Response:
|
|
||||||
"""Stream the ebook file for authenticated in-browser reading."""
|
|
||||||
ebook = self.get_object()
|
|
||||||
content_types = {
|
|
||||||
"epub": "application/epub+zip",
|
|
||||||
"pdf": "application/pdf",
|
|
||||||
}
|
|
||||||
content_type = content_types.get(ebook.format)
|
|
||||||
if not content_type:
|
|
||||||
return Response(
|
|
||||||
{"error": "Unsupported format for in-browser reading."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
if not ebook.file:
|
|
||||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
return FileResponse(
|
|
||||||
ebook.file.open("rb"),
|
|
||||||
content_type=content_type,
|
|
||||||
filename=ebook.filename(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
|
||||||
def toc(self, request: Request, pk: int | None = None) -> Response:
|
|
||||||
"""Return hierarchical table of contents."""
|
|
||||||
ebook = self.get_object()
|
|
||||||
chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")
|
|
||||||
serializer = BookChapterSerializer(chapters, many=True)
|
|
||||||
return Response({
|
|
||||||
"chapters": serializer.data,
|
|
||||||
"format": ebook.format,
|
|
||||||
"page_count": ebook.page_count,
|
|
||||||
})
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get"])
|
|
||||||
def content(self, request: Request, pk: int | None = None) -> Response:
|
|
||||||
"""Return paginated content for a given page number.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
page (int): page/chapter index to fetch (1-indexed, default: 1)
|
|
||||||
"""
|
|
||||||
ebook = self.get_object()
|
|
||||||
page = max(1, int(request.query_params.get("page", 1)))
|
|
||||||
|
|
||||||
chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook"))
|
|
||||||
total_pages = len(chapters) or ebook.page_count or 1
|
|
||||||
|
|
||||||
chapter: BookChapter | None = None
|
|
||||||
chapter_title = ""
|
|
||||||
content_html = ""
|
|
||||||
|
|
||||||
if chapters and 0 <= page - 1 < len(chapters):
|
|
||||||
ch = chapters[page - 1]
|
|
||||||
chapter_title = ch.title
|
|
||||||
content_html = _fetch_chapter_content(ebook, ch)
|
|
||||||
|
|
||||||
serializer = EBookContentSerializer(data={
|
|
||||||
"page": page,
|
|
||||||
"total_pages": total_pages,
|
|
||||||
"content": content_html,
|
|
||||||
"chapter_title": chapter_title,
|
|
||||||
"format": ebook.format,
|
|
||||||
})
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
|
||||||
def download(self, request: Request, pk: int | None = None) -> Response:
|
|
||||||
"""Track download of an e-book. Creates a DownloadRecord and returns file info."""
|
|
||||||
ebook = self.get_object()
|
|
||||||
if not ebook.file:
|
|
||||||
return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
download, created = DownloadRecord.objects.get_or_create(
|
|
||||||
user=request.user,
|
|
||||||
ebook=ebook,
|
|
||||||
defaults={"file_size": ebook.file.size if ebook.file else 0},
|
|
||||||
)
|
|
||||||
if not created:
|
|
||||||
download.file_size = ebook.file.size if ebook.file else 0
|
|
||||||
download.save(update_fields=["file_size"])
|
|
||||||
|
|
||||||
serializer = DownloadRecordSerializer(download, context={"request": request})
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"])
|
|
||||||
def downloads(self, request: Request) -> Response:
|
|
||||||
"""List all e-books the current user has downloaded."""
|
|
||||||
records = DownloadRecord.objects.filter(user=request.user).select_related(
|
|
||||||
"ebook", "ebook__reading_progress"
|
|
||||||
).prefetch_related("ebook__chapters")
|
|
||||||
page = self.paginate_queryset(records)
|
|
||||||
if page is not None:
|
|
||||||
serializer = DownloadRecordSerializer(page, many=True, context={"request": request})
|
|
||||||
return self.get_paginated_response(serializer.data)
|
|
||||||
serializer = DownloadRecordSerializer(records, many=True, context={"request": request})
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["delete"], url_path="downloads/(?P<download_pk>[^/.]+)")
|
|
||||||
def delete_download(self, request: Request, download_pk: str | None = None) -> Response:
|
|
||||||
"""Delete a download record."""
|
|
||||||
try:
|
|
||||||
download = DownloadRecord.objects.get(pk=download_pk, user=request.user)
|
|
||||||
except DownloadRecord.DoesNotExist:
|
|
||||||
return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
download.delete()
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str:
|
|
||||||
"""Fetch HTML content for a chapter from the e-book file."""
|
|
||||||
if ebook.format == "epub":
|
|
||||||
return _fetch_epub_chapter_content(ebook.file.path, chapter)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str:
|
|
||||||
"""Extract HTML content of a specific EPUB chapter by href."""
|
|
||||||
try:
|
|
||||||
from ebooklib import epub
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
except ImportError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
book = epub.read_epub(file_path)
|
|
||||||
href = chapter.href or ""
|
|
||||||
# Find the item by href
|
|
||||||
for item in book.get_items():
|
|
||||||
item_name = item.get_name() or ""
|
|
||||||
if href and (item_name.endswith(href) or href.endswith(item_name)):
|
|
||||||
content = item.get_content()
|
|
||||||
soup = BeautifulSoup(content, "html.parser")
|
|
||||||
# Clean up — remove body/html/head wrappers, keep inner content
|
|
||||||
body = soup.find("body")
|
|
||||||
if body:
|
|
||||||
return str(body)
|
|
||||||
return str(soup)
|
|
||||||
return ""
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "PATCH"])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def book_reading_settings_view(request: Request) -> Response:
|
|
||||||
"""Get or update reading settings via the books API contract."""
|
|
||||||
settings, _created = ReadingSettings.objects.get_or_create(user=request.user)
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
serializer = BookReadingSettingsSerializer(settings)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data)
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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)
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from apps.groups.models import (
|
|
||||||
GroupMeeting,
|
|
||||||
GroupMembership,
|
|
||||||
NotificationPreference,
|
|
||||||
PaceNotification,
|
|
||||||
ReadingGroup,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ReadingGroup)
|
|
||||||
class ReadingGroupAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["name", "created_by", "created_at"]
|
|
||||||
search_fields = ["name"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(GroupMembership)
|
|
||||||
class GroupMembershipAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["user", "group", "role", "joined_at"]
|
|
||||||
list_filter = ["role"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(GroupMeeting)
|
|
||||||
class GroupMeetingAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["group", "title", "target_section_label", "scheduled_at"]
|
|
||||||
list_filter = ["group"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(NotificationPreference)
|
|
||||||
class NotificationPreferenceAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["user", "pace_notifications_enabled", "reminder_enabled", "frequency"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PaceNotification)
|
|
||||||
class PaceNotificationAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ["user", "meeting", "status", "created_at"]
|
|
||||||
list_filter = ["status"]
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class GroupsConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "apps.groups"
|
|
||||||
verbose_name = "Reading Groups"
|
|
||||||
label = "groups"
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# Generated by Django 5.1.7 on 2026-06-20 19:22
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('books', '0003_readingprogress_epub_location'),
|
|
||||||
('users', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='NotificationPreference',
|
|
||||||
fields=[
|
|
||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='notification_preference', serialize=False, to=settings.AUTH_USER_MODEL)),
|
|
||||||
('pace_notifications_enabled', models.BooleanField(default=True)),
|
|
||||||
('reminder_enabled', models.BooleanField(default=True)),
|
|
||||||
('frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly')], default='daily', max_length=16)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Notification Preference',
|
|
||||||
'verbose_name_plural': 'Notification Preferences',
|
|
||||||
'db_table': 'groups_notification_preference',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ReadingGroup',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(db_index=True, max_length=256)),
|
|
||||||
('description', models.TextField(blank=True, default='')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reading_groups', to='books.book')),
|
|
||||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_groups', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('ebook', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reading_groups', to='books.ebook')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Reading Group',
|
|
||||||
'verbose_name_plural': 'Reading Groups',
|
|
||||||
'db_table': 'groups_reading_group',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='GroupMeeting',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('title', models.CharField(blank=True, default='', max_length=512)),
|
|
||||||
('scheduled_at', models.DateTimeField(db_index=True)),
|
|
||||||
('target_section', models.PositiveIntegerField(default=0, help_text='Target chapter/section index to reach by this meeting')),
|
|
||||||
('target_section_label', models.CharField(blank=True, default='', help_text="Human-readable label for the target section (e.g. 'Section 3')", max_length=512)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='groups.readinggroup')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Group Meeting',
|
|
||||||
'verbose_name_plural': 'Group Meetings',
|
|
||||||
'db_table': 'groups_meeting',
|
|
||||||
'ordering': ['scheduled_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='PaceNotification',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('dismissed', 'Dismissed')], default='pending', max_length=16)),
|
|
||||||
('current_section', models.PositiveIntegerField(default=0)),
|
|
||||||
('target_section', models.PositiveIntegerField(default=0)),
|
|
||||||
('ahead_behind_delta', models.IntegerField(default=0)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('ebook', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pace_notifications', to='books.ebook')),
|
|
||||||
('meeting', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pace_notifications', to='groups.groupmeeting')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pace_notifications', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Pace Notification',
|
|
||||||
'verbose_name_plural': 'Pace Notifications',
|
|
||||||
'db_table': 'groups_pace_notification',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
'indexes': [models.Index(fields=['user', 'meeting', 'ebook'], name='groups_pace_user_id_18e929_idx')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='GroupMembership',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('role', models.CharField(choices=[('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
|
|
||||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
|
|
||||||
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.readinggroup')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Group Membership',
|
|
||||||
'verbose_name_plural': 'Group Memberships',
|
|
||||||
'db_table': 'groups_membership',
|
|
||||||
'indexes': [models.Index(fields=['user', 'group'], name='groups_memb_user_id_9cda7e_idx')],
|
|
||||||
'unique_together': {('user', 'group')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class FrequencyChoice(models.TextChoices):
|
|
||||||
DAILY = "daily", "Daily"
|
|
||||||
WEEKLY = "weekly", "Weekly"
|
|
||||||
|
|
||||||
|
|
||||||
class MembershipRole(models.TextChoices):
|
|
||||||
ADMIN = "admin", "Admin"
|
|
||||||
MEMBER = "member", "Member"
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationStatus(models.TextChoices):
|
|
||||||
PENDING = "pending", "Pending"
|
|
||||||
SENT = "sent", "Sent"
|
|
||||||
DISMISSED = "dismissed", "Dismissed"
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingGroup(models.Model):
|
|
||||||
"""A group of users reading a book together with scheduled meetings."""
|
|
||||||
|
|
||||||
name = models.CharField(max_length=256, db_index=True)
|
|
||||||
description = models.TextField(blank=True, default="")
|
|
||||||
book = models.ForeignKey(
|
|
||||||
"books.Book",
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name="reading_groups",
|
|
||||||
)
|
|
||||||
ebook = models.ForeignKey(
|
|
||||||
"books.EBook",
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name="reading_groups",
|
|
||||||
)
|
|
||||||
created_by = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="created_groups",
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "groups_reading_group"
|
|
||||||
verbose_name = "Reading Group"
|
|
||||||
verbose_name_plural = "Reading Groups"
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
|
|
||||||
class GroupMembership(models.Model):
|
|
||||||
"""User membership in a reading group."""
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="group_memberships",
|
|
||||||
)
|
|
||||||
group = models.ForeignKey(
|
|
||||||
ReadingGroup,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="memberships",
|
|
||||||
)
|
|
||||||
role = models.CharField(
|
|
||||||
max_length=16,
|
|
||||||
choices=MembershipRole.choices,
|
|
||||||
default=MembershipRole.MEMBER,
|
|
||||||
)
|
|
||||||
joined_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "groups_membership"
|
|
||||||
verbose_name = "Group Membership"
|
|
||||||
verbose_name_plural = "Group Memberships"
|
|
||||||
unique_together = [("user", "group")]
|
|
||||||
indexes = [models.Index(fields=["user", "group"])]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.user} in {self.group}"
|
|
||||||
|
|
||||||
|
|
||||||
class GroupMeeting(models.Model):
|
|
||||||
"""A scheduled meeting for a reading group with a target section."""
|
|
||||||
|
|
||||||
group = models.ForeignKey(
|
|
||||||
ReadingGroup,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="meetings",
|
|
||||||
)
|
|
||||||
title = models.CharField(max_length=512, blank=True, default="")
|
|
||||||
scheduled_at = models.DateTimeField(db_index=True)
|
|
||||||
target_section = models.PositiveIntegerField(
|
|
||||||
default=0,
|
|
||||||
help_text="Target chapter/section index to reach by this meeting",
|
|
||||||
)
|
|
||||||
target_section_label = models.CharField(
|
|
||||||
max_length=512,
|
|
||||||
blank=True,
|
|
||||||
default="",
|
|
||||||
help_text="Human-readable label for the target section (e.g. 'Section 3')",
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "groups_meeting"
|
|
||||||
verbose_name = "Group Meeting"
|
|
||||||
verbose_name_plural = "Group Meetings"
|
|
||||||
ordering = ["scheduled_at"]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.group.name} — {self.title or self.target_section_label} ({self.scheduled_at.date()})"
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationPreference(models.Model):
|
|
||||||
"""Per-user notification settings for reading pace alerts."""
|
|
||||||
|
|
||||||
user = models.OneToOneField(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="notification_preference",
|
|
||||||
primary_key=True,
|
|
||||||
)
|
|
||||||
pace_notifications_enabled = models.BooleanField(default=True)
|
|
||||||
reminder_enabled = models.BooleanField(default=True)
|
|
||||||
frequency = models.CharField(
|
|
||||||
max_length=16,
|
|
||||||
choices=FrequencyChoice.choices,
|
|
||||||
default=FrequencyChoice.DAILY,
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "groups_notification_preference"
|
|
||||||
verbose_name = "Notification Preference"
|
|
||||||
verbose_name_plural = "Notification Preferences"
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.user} — pace={'on' if self.pace_notifications_enabled else 'off'}, freq={self.frequency}"
|
|
||||||
|
|
||||||
|
|
||||||
class PaceNotification(models.Model):
|
|
||||||
"""Tracks reading pace notifications sent to users to avoid duplicates."""
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="pace_notifications",
|
|
||||||
)
|
|
||||||
meeting = models.ForeignKey(
|
|
||||||
GroupMeeting,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="pace_notifications",
|
|
||||||
)
|
|
||||||
ebook = models.ForeignKey(
|
|
||||||
"books.EBook",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="pace_notifications",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
status = models.CharField(
|
|
||||||
max_length=16,
|
|
||||||
choices=NotificationStatus.choices,
|
|
||||||
default=NotificationStatus.PENDING,
|
|
||||||
)
|
|
||||||
current_section = models.PositiveIntegerField(default=0)
|
|
||||||
target_section = models.PositiveIntegerField(default=0)
|
|
||||||
ahead_behind_delta = models.IntegerField(default=0)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "groups_pace_notification"
|
|
||||||
verbose_name = "Pace Notification"
|
|
||||||
verbose_name_plural = "Pace Notifications"
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["user", "meeting", "ebook"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.user} — meeting {self.meeting_id}: {self.get_status_display()}"
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from apps.groups.models import (
|
|
||||||
FrequencyChoice,
|
|
||||||
GroupMeeting,
|
|
||||||
GroupMembership,
|
|
||||||
NotificationPreference,
|
|
||||||
PaceNotification,
|
|
||||||
ReadingGroup,
|
|
||||||
)
|
|
||||||
from apps.groups.services.pace import PaceStatus
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationPreferenceSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = NotificationPreference
|
|
||||||
fields = [
|
|
||||||
"pace_notifications_enabled",
|
|
||||||
"reminder_enabled",
|
|
||||||
"frequency",
|
|
||||||
]
|
|
||||||
|
|
||||||
def validate_frequency(self, value: str) -> str:
|
|
||||||
valid = {choice[0] for choice in FrequencyChoice.choices}
|
|
||||||
if value not in valid:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
f"Frequency must be one of: {', '.join(valid)}"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class GroupMeetingSerializer(serializers.ModelSerializer):
|
|
||||||
group_name = serializers.CharField(source="group.name", read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = GroupMeeting
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"group",
|
|
||||||
"group_name",
|
|
||||||
"title",
|
|
||||||
"scheduled_at",
|
|
||||||
"target_section",
|
|
||||||
"target_section_label",
|
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "created_at"]
|
|
||||||
|
|
||||||
def validate_target_section(self, value: int) -> int:
|
|
||||||
if value < 0:
|
|
||||||
raise serializers.ValidationError("Target section must be non-negative.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingGroupSerializer(serializers.ModelSerializer):
|
|
||||||
member_count = serializers.SerializerMethodField()
|
|
||||||
meeting_count = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ReadingGroup
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"book",
|
|
||||||
"ebook",
|
|
||||||
"created_by",
|
|
||||||
"created_at",
|
|
||||||
"member_count",
|
|
||||||
"meeting_count",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "created_by", "created_at"]
|
|
||||||
|
|
||||||
def get_member_count(self, obj: ReadingGroup) -> int:
|
|
||||||
return obj.memberships.count()
|
|
||||||
|
|
||||||
def get_meeting_count(self, obj: ReadingGroup) -> int:
|
|
||||||
return obj.meetings.count()
|
|
||||||
|
|
||||||
|
|
||||||
class GroupMembershipSerializer(serializers.ModelSerializer):
|
|
||||||
user_email = serializers.CharField(source="user.email", read_only=True)
|
|
||||||
group_name = serializers.CharField(source="group.name", read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = GroupMembership
|
|
||||||
fields = ["id", "user", "user_email", "group", "group_name", "role", "joined_at"]
|
|
||||||
read_only_fields = ["id", "joined_at"]
|
|
||||||
|
|
||||||
|
|
||||||
class PaceNotificationSerializer(serializers.ModelSerializer):
|
|
||||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = PaceNotification
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"user",
|
|
||||||
"meeting",
|
|
||||||
"ebook",
|
|
||||||
"status",
|
|
||||||
"status_display",
|
|
||||||
"current_section",
|
|
||||||
"target_section",
|
|
||||||
"ahead_behind_delta",
|
|
||||||
"created_at",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "created_at"]
|
|
||||||
|
|
||||||
|
|
||||||
class PaceStatusSerializer(serializers.Serializer):
|
|
||||||
meeting_id = serializers.IntegerField()
|
|
||||||
meeting_title = serializers.CharField()
|
|
||||||
scheduled_at = serializers.CharField()
|
|
||||||
target_section = serializers.IntegerField()
|
|
||||||
target_section_label = serializers.CharField()
|
|
||||||
current_section = serializers.IntegerField()
|
|
||||||
ahead_behind_delta = serializers.IntegerField()
|
|
||||||
status = serializers.CharField()
|
|
||||||
days_until_meeting = serializers.IntegerField()
|
|
||||||
sections_to_catch_up = serializers.IntegerField()
|
|
||||||
ebook_id = serializers.IntegerField()
|
|
||||||
ebook_title = serializers.CharField()
|
|
||||||
|
|
||||||
|
|
||||||
class ReminderSerializer(serializers.Serializer):
|
|
||||||
meeting_id = serializers.IntegerField()
|
|
||||||
meeting_title = serializers.CharField()
|
|
||||||
scheduled_at = serializers.CharField()
|
|
||||||
target_section = serializers.IntegerField()
|
|
||||||
target_section_label = serializers.CharField()
|
|
||||||
group_name = serializers.CharField()
|
|
||||||
hours_until = serializers.IntegerField()
|
|
||||||
ebook_id = serializers.IntegerField()
|
|
||||||
ebook_title = serializers.CharField()
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
"""Pace calculation service for reading progress vs meeting targets."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from apps.books.models import EBook, ReadingProgress
|
|
||||||
from apps.groups.models import (
|
|
||||||
GroupMeeting,
|
|
||||||
GroupMembership,
|
|
||||||
NotificationPreference,
|
|
||||||
PaceNotification,
|
|
||||||
NotificationStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class PaceStatus(TypedDict):
|
|
||||||
meeting_id: int
|
|
||||||
meeting_title: str
|
|
||||||
scheduled_at: str
|
|
||||||
target_section: int
|
|
||||||
target_section_label: str
|
|
||||||
current_section: int
|
|
||||||
ahead_behind_delta: int
|
|
||||||
status: str # "ahead", "behind", "on_track", "completed"
|
|
||||||
days_until_meeting: int
|
|
||||||
sections_to_catch_up: int
|
|
||||||
ebook_id: int
|
|
||||||
ebook_title: str
|
|
||||||
|
|
||||||
|
|
||||||
def get_relevant_ebooks_for_user(user) -> QuerySet[EBook]:
|
|
||||||
"""Return ebooks that belong to groups the user is a member of."""
|
|
||||||
group_ids = (
|
|
||||||
GroupMembership.objects.filter(user=user)
|
|
||||||
.values_list("group_id", flat=True)
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
EBook.objects.filter(reading_groups__in=group_ids)
|
|
||||||
.select_related("reading_progress")
|
|
||||||
.prefetch_related("chapters")
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_pace(
|
|
||||||
user,
|
|
||||||
ebook: EBook | None = None,
|
|
||||||
meeting: GroupMeeting | None = None,
|
|
||||||
) -> list[PaceStatus]:
|
|
||||||
"""Calculate reading pace for a user's upcoming meetings.
|
|
||||||
|
|
||||||
Compares the user's current reading progress (chapter index) against
|
|
||||||
the target section for each upcoming meeting within 3 days.
|
|
||||||
"""
|
|
||||||
now = timezone.now()
|
|
||||||
three_days_from_now = now + timedelta(days=3)
|
|
||||||
|
|
||||||
# Find upcoming meetings for groups the user belongs to
|
|
||||||
user_group_ids = (
|
|
||||||
GroupMembership.objects.filter(user=user)
|
|
||||||
.values_list("group_id", flat=True)
|
|
||||||
)
|
|
||||||
meetings_qs = GroupMeeting.objects.filter(
|
|
||||||
group_id__in=user_group_ids,
|
|
||||||
scheduled_at__gte=now,
|
|
||||||
scheduled_at__lte=three_days_from_now,
|
|
||||||
).select_related("group", "group__ebook")
|
|
||||||
|
|
||||||
if meeting:
|
|
||||||
meetings_qs = meetings_qs.filter(pk=meeting.pk)
|
|
||||||
|
|
||||||
meetings = list(meetings_qs)
|
|
||||||
|
|
||||||
results: list[PaceStatus] = []
|
|
||||||
|
|
||||||
for m in meetings:
|
|
||||||
group_ebook = m.group.ebook
|
|
||||||
ebooks_to_check: list[EBook] = []
|
|
||||||
|
|
||||||
if ebook:
|
|
||||||
ebooks_to_check = [ebook]
|
|
||||||
elif group_ebook:
|
|
||||||
ebooks_to_check = [group_ebook]
|
|
||||||
else:
|
|
||||||
# Check all ebooks in the group
|
|
||||||
ebooks_to_check = list(
|
|
||||||
EBook.objects.filter(reading_groups=m.group)
|
|
||||||
.select_related("reading_progress")
|
|
||||||
.prefetch_related("chapters")
|
|
||||||
)
|
|
||||||
|
|
||||||
for eb in ebooks_to_check:
|
|
||||||
pace = _compute_single_pace(user, m, eb, now)
|
|
||||||
if pace:
|
|
||||||
results.append(pace)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_single_pace(
|
|
||||||
user,
|
|
||||||
meeting: GroupMeeting,
|
|
||||||
ebook: EBook,
|
|
||||||
now: datetime,
|
|
||||||
) -> PaceStatus | None:
|
|
||||||
"""Compute pace for one user + meeting + ebook combination."""
|
|
||||||
try:
|
|
||||||
progress = ebook.reading_progress
|
|
||||||
except ReadingProgress.DoesNotExist:
|
|
||||||
# No progress yet — user hasn't started reading
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Current section: based on last_page (chapter index) or computed from progress %
|
|
||||||
if progress.last_page > 0:
|
|
||||||
current_section = progress.last_page
|
|
||||||
elif progress.current_position > 0:
|
|
||||||
chapter_count = ebook.chapters.count() or ebook.page_count or 1
|
|
||||||
current_section = max(1, int((progress.current_position / 100) * chapter_count))
|
|
||||||
else:
|
|
||||||
current_section = 0
|
|
||||||
|
|
||||||
target_section = meeting.target_section or 0
|
|
||||||
if target_section == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
delta = current_section - target_section
|
|
||||||
days_until = max(0, (meeting.scheduled_at - now).days)
|
|
||||||
|
|
||||||
if current_section >= target_section:
|
|
||||||
if current_section >= target_section and progress.current_position > 0:
|
|
||||||
status = "completed"
|
|
||||||
else:
|
|
||||||
status = "ahead"
|
|
||||||
elif delta >= -1:
|
|
||||||
status = "on_track"
|
|
||||||
else:
|
|
||||||
status = "behind"
|
|
||||||
|
|
||||||
return PaceStatus(
|
|
||||||
meeting_id=meeting.pk,
|
|
||||||
meeting_title=meeting.title or meeting.target_section_label,
|
|
||||||
scheduled_at=meeting.scheduled_at.isoformat(),
|
|
||||||
target_section=target_section,
|
|
||||||
target_section_label=meeting.target_section_label,
|
|
||||||
current_section=current_section,
|
|
||||||
ahead_behind_delta=delta,
|
|
||||||
status=status,
|
|
||||||
days_until_meeting=days_until,
|
|
||||||
sections_to_catch_up=max(0, target_section - current_section),
|
|
||||||
ebook_id=ebook.pk,
|
|
||||||
ebook_title=ebook.title,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def should_send_notification(
|
|
||||||
user,
|
|
||||||
meeting: GroupMeeting,
|
|
||||||
ebook: EBook,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if a pace notification should be sent based on user preferences."""
|
|
||||||
pref, _ = NotificationPreference.objects.get_or_create(user=user)
|
|
||||||
|
|
||||||
if not pref.pace_notifications_enabled:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if notification was already sent today
|
|
||||||
today = timezone.now().date()
|
|
||||||
existing = PaceNotification.objects.filter(
|
|
||||||
user=user,
|
|
||||||
meeting=meeting,
|
|
||||||
ebook=ebook,
|
|
||||||
created_at__date=today,
|
|
||||||
).exclude(status=NotificationStatus.DISMISSED)
|
|
||||||
|
|
||||||
if existing.exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Frequency check
|
|
||||||
if pref.frequency == "weekly":
|
|
||||||
week_ago = today - timedelta(days=7)
|
|
||||||
recent = PaceNotification.objects.filter(
|
|
||||||
user=user,
|
|
||||||
meeting=meeting,
|
|
||||||
ebook=ebook,
|
|
||||||
created_at__date__gte=week_ago,
|
|
||||||
).exclude(status=NotificationStatus.DISMISSED)
|
|
||||||
if recent.exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def get_reminder_meetings(user) -> list[GroupMeeting]:
|
|
||||||
"""Get meetings that are within 24 hours for reminder notifications."""
|
|
||||||
now = timezone.now()
|
|
||||||
tomorrow = now + timedelta(days=1)
|
|
||||||
|
|
||||||
user_group_ids = (
|
|
||||||
GroupMembership.objects.filter(user=user)
|
|
||||||
.values_list("group_id", flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
return list(
|
|
||||||
GroupMeeting.objects.filter(
|
|
||||||
group_id__in=user_group_ids,
|
|
||||||
scheduled_at__gte=now,
|
|
||||||
scheduled_at__lte=tomorrow,
|
|
||||||
)
|
|
||||||
.select_related("group", "group__ebook")
|
|
||||||
.order_by("scheduled_at")
|
|
||||||
)
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from django.urls import include, path
|
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
|
|
||||||
from apps.groups.views import (
|
|
||||||
GroupMeetingViewSet,
|
|
||||||
ReadingGroupViewSet,
|
|
||||||
dismiss_pace_notification_view,
|
|
||||||
notification_preferences_view,
|
|
||||||
pace_status_view,
|
|
||||||
reminder_view,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register(r"groups", ReadingGroupViewSet, basename="reading-group")
|
|
||||||
router.register(r"meetings", GroupMeetingViewSet, basename="group-meeting")
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("", include(router.urls)),
|
|
||||||
path("notifications/preferences/", notification_preferences_view, name="notification-preferences"),
|
|
||||||
path("notifications/pace/", pace_status_view, name="pace-status"),
|
|
||||||
path("notifications/dismiss/", dismiss_pace_notification_view, name="dismiss-pace"),
|
|
||||||
path("notifications/reminders/", reminder_view, name="meeting-reminders"),
|
|
||||||
]
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
from rest_framework import permissions, status, viewsets
|
|
||||||
from rest_framework.decorators import action, api_view, permission_classes
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from apps.books.models import EBook
|
|
||||||
from apps.groups.models import (
|
|
||||||
GroupMeeting,
|
|
||||||
GroupMembership,
|
|
||||||
NotificationPreference,
|
|
||||||
PaceNotification,
|
|
||||||
NotificationStatus,
|
|
||||||
ReadingGroup,
|
|
||||||
)
|
|
||||||
from apps.groups.serializers import (
|
|
||||||
GroupMeetingSerializer,
|
|
||||||
GroupMembershipSerializer,
|
|
||||||
NotificationPreferenceSerializer,
|
|
||||||
PaceNotificationSerializer,
|
|
||||||
PaceStatusSerializer,
|
|
||||||
ReadingGroupSerializer,
|
|
||||||
ReminderSerializer,
|
|
||||||
)
|
|
||||||
from apps.groups.services.pace import (
|
|
||||||
calculate_pace,
|
|
||||||
get_reminder_meetings,
|
|
||||||
should_send_notification,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class IsGroupMember(permissions.BasePermission):
|
|
||||||
"""Only allow group members to access group resources."""
|
|
||||||
|
|
||||||
def has_object_permission(self, request: Request, view: object, obj: ReadingGroup) -> bool:
|
|
||||||
return obj.memberships.filter(user=request.user).exists()
|
|
||||||
|
|
||||||
|
|
||||||
class ReadingGroupViewSet(viewsets.ModelViewSet):
|
|
||||||
permission_classes = [IsAuthenticated, IsGroupMember]
|
|
||||||
serializer_class = ReadingGroupSerializer
|
|
||||||
|
|
||||||
def get_queryset(self) -> QuerySet[ReadingGroup]:
|
|
||||||
return (
|
|
||||||
ReadingGroup.objects.filter(memberships__user=self.request.user)
|
|
||||||
.prefetch_related("memberships", "meetings")
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
def perform_create(self, serializer) -> None:
|
|
||||||
group = serializer.save(created_by=self.request.user)
|
|
||||||
GroupMembership.objects.create(
|
|
||||||
user=self.request.user,
|
|
||||||
group=group,
|
|
||||||
role="admin",
|
|
||||||
)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], url_path="all")
|
|
||||||
def all_groups(self, request: Request) -> Response:
|
|
||||||
"""Return all groups (for discovery)."""
|
|
||||||
qs = ReadingGroup.objects.all().prefetch_related("memberships", "meetings")
|
|
||||||
page = self.paginate_queryset(qs)
|
|
||||||
if page is not None:
|
|
||||||
serializer = self.get_serializer(page, many=True)
|
|
||||||
return self.get_paginated_response(serializer.data)
|
|
||||||
serializer = self.get_serializer(qs, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
|
|
||||||
class GroupMeetingViewSet(viewsets.ModelViewSet):
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
serializer_class = GroupMeetingSerializer
|
|
||||||
|
|
||||||
def get_queryset(self) -> QuerySet[GroupMeeting]:
|
|
||||||
user_group_ids = (
|
|
||||||
GroupMembership.objects.filter(user=self.request.user)
|
|
||||||
.values_list("group_id", flat=True)
|
|
||||||
)
|
|
||||||
return GroupMeeting.objects.filter(group_id__in=user_group_ids).select_related(
|
|
||||||
"group"
|
|
||||||
)
|
|
||||||
|
|
||||||
def perform_create(self, serializer) -> None:
|
|
||||||
group = serializer.validated_data["group"]
|
|
||||||
if not group.memberships.filter(user=self.request.user).exists():
|
|
||||||
raise permissions.PermissionDenied("You must be a group member to create meetings.")
|
|
||||||
serializer.save()
|
|
||||||
|
|
||||||
@action(detail=False, methods=["get"], url_path="upcoming")
|
|
||||||
def upcoming(self, request: Request) -> Response:
|
|
||||||
"""Return upcoming meetings for the user's groups (within 3 days)."""
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
three_days = now + timedelta(days=3)
|
|
||||||
qs = self.get_queryset().filter(
|
|
||||||
scheduled_at__gte=now,
|
|
||||||
scheduled_at__lte=three_days,
|
|
||||||
)
|
|
||||||
serializer = self.get_serializer(qs, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "PATCH"])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def notification_preferences_view(request: Request) -> Response:
|
|
||||||
"""Get or update notification preferences for the current user."""
|
|
||||||
pref, _created = NotificationPreference.objects.get_or_create(user=request.user)
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
serializer = NotificationPreferenceSerializer(pref)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
serializer = NotificationPreferenceSerializer(pref, data=request.data, partial=True)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def pace_status_view(request: Request) -> Response:
|
|
||||||
"""Get reading pace status for the current user's upcoming meetings."""
|
|
||||||
ebook_id = request.query_params.get("ebook_id")
|
|
||||||
ebook: EBook | None = None
|
|
||||||
|
|
||||||
if ebook_id:
|
|
||||||
try:
|
|
||||||
ebook = EBook.objects.select_related("reading_progress").prefetch_related("chapters").get(
|
|
||||||
pk=ebook_id, user=request.user
|
|
||||||
)
|
|
||||||
except EBook.DoesNotExist:
|
|
||||||
return Response(
|
|
||||||
{"error": "EBook not found."}, status=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
pace_data = calculate_pace(request.user, ebook=ebook)
|
|
||||||
serializer = PaceStatusSerializer(pace_data, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def dismiss_pace_notification_view(request: Request) -> Response:
|
|
||||||
"""Dismiss a pace notification so it won't show again."""
|
|
||||||
meeting_id = request.data.get("meeting_id")
|
|
||||||
ebook_id = request.data.get("ebook_id")
|
|
||||||
|
|
||||||
if not meeting_id or not ebook_id:
|
|
||||||
return Response(
|
|
||||||
{"error": "meeting_id and ebook_id are required."},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
PaceNotification.objects.filter(
|
|
||||||
user=request.user,
|
|
||||||
meeting_id=meeting_id,
|
|
||||||
ebook_id=ebook_id,
|
|
||||||
status=NotificationStatus.PENDING,
|
|
||||||
).update(status=NotificationStatus.DISMISSED)
|
|
||||||
|
|
||||||
return Response({"status": "dismissed"})
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def reminder_view(request: Request) -> Response:
|
|
||||||
"""Get pre-meeting reminders for meetings within 24 hours."""
|
|
||||||
pref, _ = NotificationPreference.objects.get_or_create(user=request.user)
|
|
||||||
if not pref.reminder_enabled:
|
|
||||||
return Response([])
|
|
||||||
|
|
||||||
meetings = get_reminder_meetings(request.user)
|
|
||||||
now_dt = __import__("django").utils.timezone.now()
|
|
||||||
|
|
||||||
reminders = []
|
|
||||||
for mtg in meetings:
|
|
||||||
group_ebook = mtg.group.ebook
|
|
||||||
ebook: EBook | None = group_ebook
|
|
||||||
if not ebook:
|
|
||||||
ebooks = list(
|
|
||||||
EBook.objects.filter(reading_groups=mtg.group).select_related("reading_progress")[:1]
|
|
||||||
)
|
|
||||||
ebook = ebooks[0] if ebooks else None
|
|
||||||
|
|
||||||
if not ebook:
|
|
||||||
continue
|
|
||||||
|
|
||||||
hours_until = max(0, int((mtg.scheduled_at - now_dt).total_seconds() / 3600))
|
|
||||||
reminders.append({
|
|
||||||
"meeting_id": mtg.pk,
|
|
||||||
"meeting_title": mtg.title or mtg.target_section_label,
|
|
||||||
"scheduled_at": mtg.scheduled_at.isoformat(),
|
|
||||||
"target_section": mtg.target_section,
|
|
||||||
"target_section_label": mtg.target_section_label,
|
|
||||||
"group_name": mtg.group.name,
|
|
||||||
"hours_until": hours_until,
|
|
||||||
"ebook_id": ebook.pk,
|
|
||||||
"ebook_title": ebook.title,
|
|
||||||
})
|
|
||||||
|
|
||||||
serializer = ReminderSerializer(reminders, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class ReaderConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "apps.reader"
|
|
||||||
verbose_name = "Reader Settings"
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# 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',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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)"
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
|
|
||||||
from apps.reader.views import reading_settings_view
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("settings/", reading_settings_view, name="reading-settings"),
|
|
||||||
]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from rest_framework import permissions, status
|
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
|
||||||
from rest_framework.request import Request
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from apps.reader.models import ReadingSettings
|
|
||||||
from apps.reader.serializers import ReadingSettingsSerializer
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "PUT", "PATCH"])
|
|
||||||
@permission_classes([permissions.IsAuthenticated])
|
|
||||||
def reading_settings_view(request: Request) -> Response:
|
|
||||||
"""Get or update the current user's reading settings.
|
|
||||||
|
|
||||||
GET → return existing settings (auto-create defaults if missing)
|
|
||||||
PUT → create or fully replace settings
|
|
||||||
PATCH → partial update
|
|
||||||
"""
|
|
||||||
user = request.user
|
|
||||||
settings, created = ReadingSettings.objects.get_or_create(user=user)
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
serializer = ReadingSettingsSerializer(settings)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
if request.method == "PUT":
|
|
||||||
serializer = ReadingSettingsSerializer(settings, data=request.data)
|
|
||||||
elif request.method == "PATCH":
|
|
||||||
serializer = ReadingSettingsSerializer(settings, data=request.data, partial=True)
|
|
||||||
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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"
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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)
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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")
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class UsersConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "apps.users"
|
|
||||||
label = "users"
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# 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()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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"),
|
|
||||||
]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from rest_framework import generics
|
|
||||||
from rest_framework.permissions import AllowAny
|
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
|
||||||
|
|
||||||
from apps.users.serializers import EmailTokenObtainPairSerializer, RegisterSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterView(generics.CreateAPIView):
|
|
||||||
serializer_class = RegisterSerializer
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
|
|
||||||
|
|
||||||
class EmailTokenObtainPairView(TokenObtainPairView):
|
|
||||||
serializer_class = EmailTokenObtainPairSerializer
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"""
|
|
||||||
Django settings for cloud-reader backend.
|
|
||||||
|
|
||||||
Generated using Django 5.1. Customised with pydantic-settings integration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from config.settings import settings
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Build paths
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Security
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SECRET_KEY = settings.DJANGO_SECRET_KEY
|
|
||||||
DEBUG = settings.DJANGO_DEBUG
|
|
||||||
ALLOWED_HOSTS = settings.DJANGO_ALLOWED_HOSTS
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Application definition
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
# Django built-in
|
|
||||||
"django.contrib.admin",
|
|
||||||
"django.contrib.auth",
|
|
||||||
"django.contrib.contenttypes",
|
|
||||||
"django.contrib.sessions",
|
|
||||||
"django.contrib.messages",
|
|
||||||
"django.contrib.staticfiles",
|
|
||||||
# Third-party
|
|
||||||
"rest_framework",
|
|
||||||
"rest_framework_simplejwt",
|
|
||||||
"corsheaders",
|
|
||||||
"django_filters",
|
|
||||||
# Local apps
|
|
||||||
"apps.users",
|
|
||||||
"apps.books",
|
|
||||||
"apps.annotations",
|
|
||||||
"apps.reader",
|
|
||||||
"apps.groups",
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
|
||||||
"django.middleware.security.SecurityMiddleware",
|
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
||||||
"django.middleware.common.CommonMiddleware",
|
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = "config.urls"
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [],
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.debug",
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = "config.wsgi.application"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Database
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"NAME": settings.DB_NAME,
|
|
||||||
"USER": settings.DB_USER,
|
|
||||||
"PASSWORD": settings.DB_PASSWORD,
|
|
||||||
"HOST": settings.DB_HOST,
|
|
||||||
"PORT": settings.DB_PORT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Auth
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
AUTH_USER_MODEL = "users.User"
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
|
||||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
|
||||||
]
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# DRF
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
REST_FRAMEWORK = {
|
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
|
||||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
|
||||||
),
|
|
||||||
"DEFAULT_PERMISSION_CLASSES": (
|
|
||||||
"rest_framework.permissions.IsAuthenticated",
|
|
||||||
),
|
|
||||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
|
||||||
"PAGE_SIZE": 50,
|
|
||||||
"DEFAULT_FILTER_BACKENDS": [
|
|
||||||
"django_filters.rest_framework.DjangoFilterBackend",
|
|
||||||
"rest_framework.filters.OrderingFilter",
|
|
||||||
"rest_framework.filters.SearchFilter",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SimpleJWT
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
SIMPLE_JWT = {
|
|
||||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=settings.JWT_ACCESS_TOKEN_LIFETIME_MINUTES),
|
|
||||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=settings.JWT_REFRESH_TOKEN_LIFETIME_DAYS),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CORS
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
CORS_ALLOWED_ORIGINS = settings.CORS_ALLOWED_ORIGINS
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# i18n
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
LANGUAGE_CODE = "en-us"
|
|
||||||
TIME_ZONE = "UTC"
|
|
||||||
USE_I18N = True
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Static / Media / Uploads
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
STATIC_URL = "static/"
|
|
||||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
|
||||||
MEDIA_URL = "/media/"
|
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
|
||||||
|
|
||||||
# Maximum upload size: 50MB
|
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
|
|
||||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 52_428_800
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Default primary key
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""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
|
||||||
+158
-35
@@ -1,47 +1,170 @@
|
|||||||
from pydantic_settings import BaseSettings
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import decouple
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
"""Application settings via pydantic-settings. Reads from env vars and .env."""
|
|
||||||
|
|
||||||
# Django
|
# ---------------------------------------------------------------------------
|
||||||
DJANGO_SECRET_KEY: str = "django-insecure-change-me-in-production"
|
# Environment
|
||||||
DJANGO_DEBUG: bool = False
|
# ---------------------------------------------------------------------------
|
||||||
DJANGO_ALLOWED_HOSTS: list[str] = ["*"]
|
config = decouple.AutoConfig(search_path=BASE_DIR / ".env")
|
||||||
|
|
||||||
# PostgreSQL
|
SECRET_KEY = config("SECRET_KEY", default="django-insecure-change-me-in-production")
|
||||||
DB_NAME: str = "cloud_reader"
|
DEBUG = config("DEBUG", default=False, cast=bool)
|
||||||
DB_USER: str = "postgres"
|
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=decouple.Csv())
|
||||||
DB_PASSWORD: str = "postgres"
|
|
||||||
DB_HOST: str = "localhost"
|
|
||||||
DB_PORT: int = 5432
|
|
||||||
|
|
||||||
# JWT
|
# ---------------------------------------------------------------------------
|
||||||
JWT_ACCESS_TOKEN_LIFETIME_MINUTES: int = 60
|
# Application definition
|
||||||
JWT_REFRESH_TOKEN_LIFETIME_DAYS: int = 7
|
# ---------------------------------------------------------------------------
|
||||||
|
INSTALLED_APPS = [
|
||||||
# CORS
|
"django.contrib.admin",
|
||||||
CORS_ALLOWED_ORIGINS: list[str] = [
|
"django.contrib.auth",
|
||||||
"http://localhost:5173",
|
"django.contrib.contenttypes",
|
||||||
"http://localhost:3000",
|
"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",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Open Library metadata enrichment
|
MIDDLEWARE = [
|
||||||
OPENLIBRARY_ENABLED: bool = True
|
"django.middleware.security.SecurityMiddleware",
|
||||||
OPENLIBRARY_PREFERRED_LANG: str = "es"
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
OPENLIBRARY_FALLBACK_LANG: str = "en"
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
OPENLIBRARY_TIMEOUT_SECONDS: float = 10.0
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
OPENLIBRARY_CONNECT_TIMEOUT_SECONDS: float = 15.0
|
"django.middleware.common.CommonMiddleware",
|
||||||
OPENLIBRARY_USER_AGENT: str = "CloudReader/1.0 (https://github.com/cloud-reader)"
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
@property
|
ROOT_URLCONF = "config.urls"
|
||||||
def DATABASE_URL(self) -> str:
|
|
||||||
return (
|
TEMPLATES = [
|
||||||
f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}"
|
{
|
||||||
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
|
"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": 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"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
|
||||||
settings = Settings()
|
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,
|
||||||
|
}
|
||||||
+7
-11
@@ -1,17 +1,13 @@
|
|||||||
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),
|
||||||
path("api/auth/", include("apps.users.urls")),
|
# API
|
||||||
path("api/books/", include("apps.books.urls")),
|
path("api/v1/auth/", include("apps.accounts.urls")),
|
||||||
path("api/annotations/", include("apps.annotations.urls")),
|
path("api/v1/documents/", include("apps.documents.urls")),
|
||||||
path("api/reader/", include("apps.reader.urls")),
|
path("api/v1/collections/", include("apps.collections.urls")),
|
||||||
path("api/groups/", include("apps.groups.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.django")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
+2
-3
@@ -1,13 +1,12 @@
|
|||||||
#!/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():
|
def main() -> None:
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||||
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:
|
||||||
|
|||||||
+28
-20
@@ -1,25 +1,33 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "backend"
|
name = "cloud-reader-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 = [
|
||||||
"coverage==7.6.10",
|
"django>=5.1,<6.0",
|
||||||
"django==5.1.7",
|
"djangorestframework>=3.15,<4.0",
|
||||||
"django-cors-headers==4.6.0",
|
"django-cors-headers>=4.3",
|
||||||
"django-filter==25.1",
|
"django-filter>=24.3",
|
||||||
"djangorestframework==3.15.2",
|
"psycopg2-binary>=2.9",
|
||||||
"djangorestframework-simplejwt==5.4.0",
|
"python-decouple>=3.8",
|
||||||
"gunicorn==23.0.0",
|
"djangorestframework-simplejwt>=5.3",
|
||||||
"pillow>=11.0.0",
|
"drf-spectacular>=0.27",
|
||||||
"psycopg2-binary==2.9.10",
|
"gunicorn>=22.0",
|
||||||
"pydantic==2.10.5",
|
"whitenoise>=6.6",
|
||||||
"pydantic-settings==2.7.1",
|
"Pillow>=10.3",
|
||||||
"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"
|
||||||
+4
-7
@@ -1,8 +1,5 @@
|
|||||||
[tool:pytest]
|
# pytest
|
||||||
DJANGO_SETTINGS_MODULE = config.django
|
DJANGO_SETTINGS_MODULE = config.settings
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
testpaths = apps
|
django_find_project = false
|
||||||
|
testpaths = apps/
|
||||||
[coverage:run]
|
|
||||||
source = apps
|
|
||||||
omit = */tests/*,*/migrations/*,*/admin.py,*/apps.py
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
Django==5.1.7
|
|
||||||
djangorestframework==3.15.2
|
|
||||||
djangorestframework-simplejwt==5.4.0
|
|
||||||
django-filter==25.1
|
|
||||||
django-cors-headers==4.6.0
|
|
||||||
psycopg2-binary==2.9.10
|
|
||||||
pydantic==2.10.5
|
|
||||||
pydantic-settings==2.7.1
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
gunicorn==23.0.0
|
|
||||||
Pillow>=11.0.0
|
|
||||||
pytest==8.3.4
|
|
||||||
pytest-django==4.9.0
|
|
||||||
pytest-cov==6.0.0
|
|
||||||
coverage==7.6.10
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-r production.txt
|
||||||
|
pytest>=8.0
|
||||||
|
pytest-django>=4.8
|
||||||
|
pytest-cov>=5.0
|
||||||
|
model-bakery>=1.17
|
||||||
|
ruff>=0.5
|
||||||
|
ipdb>=0.13
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user