load project
This commit is contained in:
29
.env.example
Normal file
29
.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# PostgreSQL
|
||||
POSTGRES_USER=doceditor
|
||||
POSTGRES_PASSWORD=doceditor
|
||||
POSTGRES_DB=doceditor
|
||||
|
||||
# Application
|
||||
SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# URLs (used in emails and CORS)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Ports exposed on host
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=5173
|
||||
|
||||
# SMTP (optional — codes logged to backend console if empty)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@example.com
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
ACTIVATION_CODE_EXPIRE_HOURS=24
|
||||
PASSWORD_RESET_CODE_EXPIRE_HOURS=1
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
backend/.venv/
|
||||
backend/app.db
|
||||
backend/uploads/
|
||||
backend/exports/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
164
README.md
Normal file
164
README.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Document Template Editor
|
||||
|
||||
FastAPI + React application for managing Jinja2-annotated `.docx` document templates.
|
||||
|
||||
## Features
|
||||
|
||||
- **Authentication** with JWT (register/login)
|
||||
- **User roles**: `user` and `admin` with admin user management panel
|
||||
- **Initial admin**: seeded from `.env` on startup
|
||||
- **Template upload**: load a `.docx` file with Jinja2 placeholders
|
||||
- **Auto-detected form fields**: variables become text, textarea, number, date, table cell, or repeating table row fields
|
||||
- **Style preservation**: font, alignment, table cell styles captured from the original document
|
||||
- **Document preview**: render filled template as HTML preview
|
||||
- **Export**: download as `.docx` or `.pdf`
|
||||
- **Reusable templates**: fill the same template multiple times
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/ # FastAPI API
|
||||
frontend/ # React + Vite UI
|
||||
samples/ # Sample .docx templates
|
||||
```
|
||||
|
||||
## Quick Start (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
| Service | URL |
|
||||
|----------|-----|
|
||||
| Frontend | http://localhost:5173 |
|
||||
| Backend | http://localhost:8000 |
|
||||
| API docs | http://localhost:8000/docs |
|
||||
|
||||
Default admin: `admin` / `admin123` (configure in `.env`).
|
||||
|
||||
Stop: `docker compose down` · Remove data: `docker compose down -v`
|
||||
|
||||
## Quick Start (Local)
|
||||
|
||||
Requires PostgreSQL running locally (or use Docker only for the database):
|
||||
|
||||
```bash
|
||||
docker compose up db -d
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173
|
||||
|
||||
### Create Sample Template
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
python ../samples/create_sample_template.py
|
||||
```
|
||||
|
||||
## Jinja2 Syntax in .docx
|
||||
|
||||
Use standard Jinja2 in your Word document:
|
||||
|
||||
| Pattern | Purpose |
|
||||
|---------|---------|
|
||||
| `{{ client_name }}` | Simple text variable |
|
||||
| `{{ description }}` | Auto-detected as textarea if name contains "description" |
|
||||
| `{%tr for item in items %}` | Repeating table row (docxtpl syntax) |
|
||||
| `{{ item.product }}` | Field inside table row loop |
|
||||
|
||||
### Example Table Row
|
||||
|
||||
In a Word table row, write:
|
||||
|
||||
```
|
||||
{%tr for item in items %}
|
||||
{{ item.name }} | {{ item.qty }} | {{ item.price }}
|
||||
{%tr endfor %}
|
||||
```
|
||||
|
||||
The parser detects `items` as a `table_row` field and `item.name`, `item.qty`, `item.price` as child `table_cell` fields with preserved table styles.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/auth/register` | Register user |
|
||||
| POST | `/api/auth/login` | Login (OAuth2 form) |
|
||||
| GET | `/api/auth/me` | Current user |
|
||||
| GET | `/api/users` | List users (admin) |
|
||||
| POST | `/api/users` | Create user (admin) |
|
||||
| PATCH | `/api/users/{id}` | Update user (admin) |
|
||||
| DELETE | `/api/users/{id}` | Delete user (admin) |
|
||||
| GET | `/api/templates` | List templates |
|
||||
| POST | `/api/templates` | Upload template (.docx) |
|
||||
| GET | `/api/templates/{id}` | Get template with variables |
|
||||
| POST | `/api/documents` | Create filled document |
|
||||
| POST | `/api/documents/preview` | Preview without saving |
|
||||
| GET | `/api/documents/{id}/export/docx` | Export DOCX |
|
||||
| GET | `/api/documents/{id}/export/pdf` | Export PDF |
|
||||
|
||||
## PDF Export
|
||||
|
||||
PDF export uses LibreOffice headless if available:
|
||||
|
||||
```bash
|
||||
# ALT Linux / Fedora
|
||||
sudo apt install libreoffice # or your distro equivalent
|
||||
```
|
||||
|
||||
Without LibreOffice, install `weasyprint` as fallback:
|
||||
|
||||
```bash
|
||||
pip install weasyprint
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `backend/.env`:
|
||||
|
||||
```env
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DATABASE_URL=postgresql+psycopg2://doceditor:doceditor@localhost:5432/doceditor
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
```
|
||||
|
||||
The admin user is created automatically on startup if it does not exist.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Login** — use admin credentials from `.env`, or register as a regular user
|
||||
2. **Upload Template** — select `.docx` with Jinja2 variables
|
||||
3. **Fill Form** — dynamic form generated from detected variables
|
||||
4. **Preview** — see rendered document with your data
|
||||
5. **Save & Export** — document saved; export as DOCX or PDF
|
||||
6. **Reuse** — use the same template again from Templates list
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: FastAPI, SQLAlchemy, docxtpl, python-docx, JWT auth
|
||||
- **Frontend**: React 18, Vite, TypeScript, React Router
|
||||
11
backend/.dockerignore
Normal file
11
backend/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
app.db
|
||||
uploads
|
||||
exports
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
23
backend/.env.example
Normal file
23
backend/.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
SECRET_KEY=change-me-in-production
|
||||
DATABASE_URL=postgresql+psycopg2://doceditor:doceditor@localhost:5432/doceditor
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173
|
||||
|
||||
# Initial admin user (created on startup if not exists)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# Frontend URL (used in activation/reset emails)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# SMTP (leave SMTP_HOST empty to log emails to console in dev)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@example.com
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
# Code expiration (hours)
|
||||
ACTIVATION_CODE_EXPIRE_HOURS=24
|
||||
PASSWORD_RESET_CODE_EXPIRE_HOURS=1
|
||||
7
backend/.gitignore
vendored
Normal file
7
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
app.db
|
||||
uploads/
|
||||
exports/
|
||||
.env
|
||||
20
backend/Dockerfile
Normal file
20
backend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libreoffice-writer \
|
||||
libreoffice-calc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
RUN mkdir -p /app/uploads /app/exports
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
87
backend/app/auth.py
Normal file
87
backend/app/auth.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas import TokenData
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (
|
||||
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, email: str) -> User | None:
|
||||
return db.query(User).filter(User.email == email).first()
|
||||
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str) -> User | None:
|
||||
user = get_user_by_username(db, username)
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.secret_key, algorithms=[settings.algorithm]
|
||||
)
|
||||
username: str | None = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = get_user_by_username(db, username=token_data.username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
if current_user.role != UserRole.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privileges required",
|
||||
)
|
||||
return current_user
|
||||
33
backend/app/config.py
Normal file
33
backend/app/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
secret_key: str = "change-me-in-production-use-openssl-rand-hex-32"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24
|
||||
database_url: str = "postgresql+psycopg2://doceditor:doceditor@localhost:5432/doceditor"
|
||||
upload_dir: str = "./uploads"
|
||||
export_dir: str = "./exports"
|
||||
admin_email: str = "admin@localhost"
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "admin123"
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173"
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = ""
|
||||
smtp_use_tls: bool = True
|
||||
activation_code_expire_hours: int = 24
|
||||
password_reset_code_expire_hours: int = 1
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
34
backend/app/database.py
Normal file
34
backend/app/database.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_is_sqlite = settings.database_url.startswith("sqlite")
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args={"check_same_thread": False} if _is_sqlite else {},
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
if _is_sqlite:
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, _connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
44
backend/app/main.py
Normal file
44
backend/app/main.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.migrations import run_migrations
|
||||
from app.models.verification_token import VerificationToken # noqa: F401
|
||||
from app.routers import auth, documents, templates, users
|
||||
from app.seed import seed_admin_user
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
os.makedirs(settings.upload_dir, exist_ok=True)
|
||||
os.makedirs(settings.export_dir, exist_ok=True)
|
||||
|
||||
with SessionLocal() as db:
|
||||
seed_admin_user(db)
|
||||
|
||||
app = FastAPI(
|
||||
title="Document Template Editor",
|
||||
description="Upload Jinja2-annotated .docx templates, fill variables, preview and export",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(templates.router)
|
||||
app.include_router(documents.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
62
backend/app/migrations.py
Normal file
62
backend/app/migrations.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app.database import engine
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
"""Apply lightweight schema migrations for existing databases."""
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
dialect = engine.dialect.name
|
||||
|
||||
if "document_templates" in tables:
|
||||
columns = {col["name"] for col in inspector.get_columns("document_templates")}
|
||||
if "is_public" not in columns:
|
||||
default = "0" if dialect == "sqlite" else "false"
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
f"ALTER TABLE document_templates "
|
||||
f"ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT {default}"
|
||||
)
|
||||
)
|
||||
|
||||
if "verification_tokens" not in tables:
|
||||
with engine.begin() as conn:
|
||||
if dialect == "postgresql":
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE verification_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code VARCHAR(10) NOT NULL,
|
||||
token_type VARCHAR(20) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE verification_tokens (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
code VARCHAR(10) NOT NULL,
|
||||
token_type VARCHAR(20) NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_verification_tokens_code "
|
||||
"ON verification_tokens (code)"
|
||||
)
|
||||
)
|
||||
14
backend/app/models/__init__.py
Normal file
14
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from app.models.document import FilledDocument
|
||||
from app.models.template import DocumentTemplate, TemplateVariable
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.verification_token import TokenType, VerificationToken
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"UserRole",
|
||||
"DocumentTemplate",
|
||||
"TemplateVariable",
|
||||
"FilledDocument",
|
||||
"VerificationToken",
|
||||
"TokenType",
|
||||
]
|
||||
23
backend/app/models/document.py
Normal file
23
backend/app/models/document.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FilledDocument(Base):
|
||||
__tablename__ = "filled_documents"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
template_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("document_templates.id", ondelete="CASCADE")
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
field_data: Mapped[str] = mapped_column(Text)
|
||||
rendered_docx_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
template = relationship("DocumentTemplate", back_populates="documents")
|
||||
owner = relationship("User", back_populates="documents")
|
||||
54
backend/app/models/template.py
Normal file
54
backend/app/models/template.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DocumentTemplate(Base):
|
||||
__tablename__ = "document_templates"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
original_filename: Mapped[str] = mapped_column(String(255))
|
||||
file_path: Mapped[str] = mapped_column(String(512))
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
is_public: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
owner = relationship("User", back_populates="templates")
|
||||
variables = relationship(
|
||||
"TemplateVariable",
|
||||
back_populates="template",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TemplateVariable.order",
|
||||
)
|
||||
documents = relationship(
|
||||
"FilledDocument",
|
||||
back_populates="template",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class TemplateVariable(Base):
|
||||
__tablename__ = "template_variables"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
template_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("document_templates.id", ondelete="CASCADE")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
label: Mapped[str] = mapped_column(String(255))
|
||||
field_type: Mapped[str] = mapped_column(String(50))
|
||||
default_value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_required: Mapped[bool] = mapped_column(default=True)
|
||||
order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
parent_variable: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
style_params: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
template = relationship("DocumentTemplate", back_populates="variables")
|
||||
40
backend/app/models/user.py
Normal file
40
backend/app/models/user.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
user = "user"
|
||||
admin = "admin"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255))
|
||||
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.user)
|
||||
is_active: Mapped[bool] = mapped_column(default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
templates = relationship(
|
||||
"DocumentTemplate",
|
||||
back_populates="owner",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
documents = relationship(
|
||||
"FilledDocument",
|
||||
back_populates="owner",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
verification_tokens = relationship(
|
||||
"VerificationToken",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
25
backend/app/models/verification_token.py
Normal file
25
backend/app/models/verification_token.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TokenType(str, enum.Enum):
|
||||
activation = "activation"
|
||||
password_reset = "password_reset"
|
||||
|
||||
|
||||
class VerificationToken(Base):
|
||||
__tablename__ = "verification_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
code: Mapped[str] = mapped_column(String(10), index=True)
|
||||
token_type: Mapped[TokenType] = mapped_column(Enum(TokenType))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="verification_tokens")
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
160
backend/app/routers/auth.py
Normal file
160
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import (
|
||||
authenticate_user,
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
get_password_hash,
|
||||
get_user_by_email,
|
||||
get_user_by_username,
|
||||
)
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.verification_token import TokenType
|
||||
from app.schemas import (
|
||||
ActivateAccountRequest,
|
||||
ForgotPasswordRequest,
|
||||
MessageResponse,
|
||||
RegisterResponse,
|
||||
ResetPasswordRequest,
|
||||
Token,
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services.email_service import send_activation_email, send_password_reset_email
|
||||
from app.services.verification import create_verification_token, verify_code
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register(user_data: UserCreate, db: Annotated[Session, Depends(get_db)]):
|
||||
if user_data.password != user_data.confirm_password:
|
||||
raise HTTPException(status_code=400, detail="Passwords do not match")
|
||||
|
||||
if get_user_by_email(db, user_data.email):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
if get_user_by_username(db, user_data.username):
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
role=UserRole.user,
|
||||
is_active=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
token = create_verification_token(db, user, TokenType.activation)
|
||||
db.commit()
|
||||
|
||||
send_activation_email(user.email, user.username, token.code)
|
||||
|
||||
return RegisterResponse(
|
||||
message="Registration successful. Check your email for the activation code.",
|
||||
email=user.email,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/activate", response_model=MessageResponse)
|
||||
def activate_account(
|
||||
data: ActivateAccountRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
user = verify_code(db, data.email, data.code, TokenType.activation)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired activation code")
|
||||
|
||||
user.is_active = True
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(message="Account activated. You can now log in.")
|
||||
|
||||
|
||||
@router.post("/resend-activation", response_model=MessageResponse)
|
||||
def resend_activation(
|
||||
data: ForgotPasswordRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
user = get_user_by_email(db, data.email)
|
||||
if not user:
|
||||
return MessageResponse(
|
||||
message="If the email exists, a new activation code has been sent."
|
||||
)
|
||||
|
||||
if user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Account is already activated")
|
||||
|
||||
token = create_verification_token(db, user, TokenType.activation)
|
||||
db.commit()
|
||||
send_activation_email(user.email, user.username, token.code)
|
||||
|
||||
return MessageResponse(
|
||||
message="If the email exists, a new activation code has been sent."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_model=MessageResponse)
|
||||
def forgot_password(
|
||||
data: ForgotPasswordRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
user = get_user_by_email(db, data.email)
|
||||
if user and user.is_active:
|
||||
token = create_verification_token(db, user, TokenType.password_reset)
|
||||
db.commit()
|
||||
send_password_reset_email(user.email, user.username, token.code)
|
||||
|
||||
return MessageResponse(
|
||||
message="If the email exists, a password reset code has been sent."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
def reset_password(
|
||||
data: ResetPasswordRequest,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
if data.password != data.confirm_password:
|
||||
raise HTTPException(status_code=400, detail="Passwords do not match")
|
||||
|
||||
user = verify_code(db, data.email, data.code, TokenType.password_reset)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired reset code")
|
||||
|
||||
user.hashed_password = get_password_hash(data.password)
|
||||
db.commit()
|
||||
|
||||
return MessageResponse(message="Password updated. You can now log in.")
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account not activated. Check your email for the activation code.",
|
||||
)
|
||||
token = create_access_token(data={"sub": user.username})
|
||||
return Token(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: Annotated[User, Depends(get_current_user)]):
|
||||
return current_user
|
||||
243
backend/app/routers/documents.py
Normal file
243
backend/app/routers/documents.py
Normal file
@@ -0,0 +1,243 @@
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.document import FilledDocument
|
||||
from app.models.template import DocumentTemplate
|
||||
from app.models.user import User
|
||||
from app.schemas import (
|
||||
FilledDocumentCreate,
|
||||
FilledDocumentResponse,
|
||||
FilledDocumentUpdate,
|
||||
PreviewResponse,
|
||||
)
|
||||
from app.services.access import accessible_document, accessible_template, is_admin
|
||||
from app.services.docx_renderer import docx_to_html, export_to_pdf, render_docx
|
||||
|
||||
router = APIRouter(prefix="/api/documents", tags=["documents"])
|
||||
|
||||
|
||||
def _doc_to_response(doc: FilledDocument) -> FilledDocumentResponse:
|
||||
field_data = json.loads(doc.field_data)
|
||||
return FilledDocumentResponse(
|
||||
id=doc.id,
|
||||
template_id=doc.template_id,
|
||||
owner_id=doc.owner_id,
|
||||
owner_username=doc.owner.username if doc.owner else None,
|
||||
name=doc.name,
|
||||
field_data=field_data,
|
||||
created_at=doc.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_template_for_document(
|
||||
db: Session, user: User, template_id: int
|
||||
) -> DocumentTemplate:
|
||||
template = accessible_template(db, user, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
return template
|
||||
|
||||
|
||||
def _rerender_document(doc: FilledDocument, template: DocumentTemplate) -> None:
|
||||
field_data = json.loads(doc.field_data)
|
||||
output_path = doc.rendered_docx_path or os.path.join(
|
||||
settings.export_dir, f"{uuid.uuid4().hex}_rendered.docx"
|
||||
)
|
||||
os.makedirs(settings.export_dir, exist_ok=True)
|
||||
render_docx(template.file_path, field_data, output_path)
|
||||
doc.rendered_docx_path = output_path
|
||||
|
||||
|
||||
@router.get("", response_model=list[FilledDocumentResponse])
|
||||
def list_documents(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
query = db.query(FilledDocument).options(joinedload(FilledDocument.owner))
|
||||
if not is_admin(current_user):
|
||||
query = query.filter(FilledDocument.owner_id == current_user.id)
|
||||
docs = query.order_by(FilledDocument.created_at.desc()).all()
|
||||
return [_doc_to_response(d) for d in docs]
|
||||
|
||||
|
||||
@router.get("/{document_id}", response_model=FilledDocumentResponse)
|
||||
def get_document(
|
||||
document_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
doc = (
|
||||
db.query(FilledDocument)
|
||||
.options(joinedload(FilledDocument.owner))
|
||||
.filter(FilledDocument.id == document_id)
|
||||
.first()
|
||||
)
|
||||
if not doc or not accessible_document(db, current_user, document_id):
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return _doc_to_response(doc)
|
||||
|
||||
|
||||
@router.patch("/{document_id}", response_model=FilledDocumentResponse)
|
||||
def update_document(
|
||||
document_id: int,
|
||||
update: FilledDocumentUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
doc = (
|
||||
db.query(FilledDocument)
|
||||
.options(joinedload(FilledDocument.owner))
|
||||
.filter(FilledDocument.id == document_id)
|
||||
.first()
|
||||
)
|
||||
if not doc or not accessible_document(db, current_user, document_id):
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
if update.name is not None:
|
||||
doc.name = update.name
|
||||
if update.field_data is not None:
|
||||
doc.field_data = json.dumps(update.field_data)
|
||||
|
||||
template = db.query(DocumentTemplate).filter(
|
||||
DocumentTemplate.id == doc.template_id
|
||||
).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
_rerender_document(doc, template)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
return _doc_to_response(doc)
|
||||
|
||||
|
||||
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_document(
|
||||
document_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
doc = db.query(FilledDocument).filter(FilledDocument.id == document_id).first()
|
||||
if not doc or not accessible_document(db, current_user, document_id):
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
if doc.rendered_docx_path and os.path.exists(doc.rendered_docx_path):
|
||||
os.remove(doc.rendered_docx_path)
|
||||
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("", response_model=FilledDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_document(
|
||||
data: FilledDocumentCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
template = _get_template_for_document(db, current_user, data.template_id)
|
||||
|
||||
os.makedirs(settings.export_dir, exist_ok=True)
|
||||
output_name = f"{uuid.uuid4().hex}_rendered.docx"
|
||||
output_path = os.path.join(settings.export_dir, output_name)
|
||||
|
||||
render_docx(template.file_path, data.field_data, output_path)
|
||||
|
||||
doc = FilledDocument(
|
||||
template_id=template.id,
|
||||
owner_id=current_user.id,
|
||||
name=data.name,
|
||||
field_data=json.dumps(data.field_data),
|
||||
rendered_docx_path=output_path,
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
return _doc_to_response(doc)
|
||||
|
||||
|
||||
@router.post("/preview", response_model=PreviewResponse)
|
||||
def preview_document(
|
||||
data: FilledDocumentCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
template = _get_template_for_document(db, current_user, data.template_id)
|
||||
|
||||
os.makedirs(settings.export_dir, exist_ok=True)
|
||||
temp_path = os.path.join(settings.export_dir, f"preview_{uuid.uuid4().hex}.docx")
|
||||
|
||||
try:
|
||||
render_docx(template.file_path, data.field_data, temp_path)
|
||||
html = docx_to_html(temp_path)
|
||||
return PreviewResponse(html=html, field_data=data.field_data)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
|
||||
@router.get("/{document_id}/export/docx")
|
||||
def export_docx(
|
||||
document_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
doc = db.query(FilledDocument).filter(FilledDocument.id == document_id).first()
|
||||
if not doc or not accessible_document(db, current_user, document_id):
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
if not doc.rendered_docx_path or not os.path.exists(doc.rendered_docx_path):
|
||||
template = db.query(DocumentTemplate).filter(
|
||||
DocumentTemplate.id == doc.template_id
|
||||
).first()
|
||||
if template:
|
||||
_rerender_document(doc, template)
|
||||
db.commit()
|
||||
|
||||
if not doc.rendered_docx_path:
|
||||
raise HTTPException(status_code=404, detail="Rendered file not found")
|
||||
|
||||
return FileResponse(
|
||||
doc.rendered_docx_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
filename=f"{doc.name}.docx",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}/export/pdf")
|
||||
def export_pdf(
|
||||
document_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
doc = db.query(FilledDocument).filter(FilledDocument.id == document_id).first()
|
||||
if not doc or not accessible_document(db, current_user, document_id):
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
if not doc.rendered_docx_path or not os.path.exists(doc.rendered_docx_path):
|
||||
template = db.query(DocumentTemplate).filter(
|
||||
DocumentTemplate.id == doc.template_id
|
||||
).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
_rerender_document(doc, template)
|
||||
db.commit()
|
||||
|
||||
docx_path = doc.rendered_docx_path
|
||||
if not docx_path:
|
||||
raise HTTPException(status_code=404, detail="Rendered file not found")
|
||||
|
||||
pdf_path = docx_path.replace(".docx", ".pdf")
|
||||
try:
|
||||
export_to_pdf(docx_path, pdf_path)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{doc.name}.pdf")
|
||||
274
backend/app/routers/templates.py
Normal file
274
backend/app/routers/templates.py
Normal file
@@ -0,0 +1,274 @@
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.template import DocumentTemplate, TemplateVariable
|
||||
from app.models.user import User
|
||||
from app.schemas import (
|
||||
DocumentTemplateListItem,
|
||||
DocumentTemplateResponse,
|
||||
TemplateUpdate,
|
||||
TemplateVariableResponse,
|
||||
TemplateVariableUpdate,
|
||||
)
|
||||
from app.services.access import (
|
||||
accessible_template,
|
||||
accessible_templates_query,
|
||||
can_manage_template,
|
||||
is_admin,
|
||||
manageable_template,
|
||||
)
|
||||
from app.services.docx_parser import parse_docx_template
|
||||
|
||||
router = APIRouter(prefix="/api/templates", tags=["templates"])
|
||||
|
||||
|
||||
def _owner_username(template: DocumentTemplate) -> str | None:
|
||||
return template.owner.username if template.owner else None
|
||||
|
||||
|
||||
def _template_to_response(template: DocumentTemplate, current_user: User) -> DocumentTemplateResponse:
|
||||
variables = []
|
||||
for var in template.variables:
|
||||
style = None
|
||||
if var.style_params:
|
||||
try:
|
||||
style = json.loads(var.style_params)
|
||||
except json.JSONDecodeError:
|
||||
style = None
|
||||
variables.append(
|
||||
TemplateVariableResponse(
|
||||
id=var.id,
|
||||
name=var.name,
|
||||
label=var.label,
|
||||
field_type=var.field_type,
|
||||
default_value=var.default_value,
|
||||
is_required=var.is_required,
|
||||
order=var.order,
|
||||
parent_variable=var.parent_variable,
|
||||
style_params=style,
|
||||
)
|
||||
)
|
||||
return DocumentTemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
original_filename=template.original_filename,
|
||||
owner_id=template.owner_id,
|
||||
owner_username=_owner_username(template),
|
||||
is_public=template.is_public,
|
||||
is_owner=template.owner_id == current_user.id,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
|
||||
def _template_to_list_item(template: DocumentTemplate, current_user: User) -> DocumentTemplateListItem:
|
||||
return DocumentTemplateListItem(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
original_filename=template.original_filename,
|
||||
owner_id=template.owner_id,
|
||||
owner_username=_owner_username(template),
|
||||
is_public=template.is_public,
|
||||
is_owner=template.owner_id == current_user.id,
|
||||
created_at=template.created_at,
|
||||
variable_count=len(template.variables),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[DocumentTemplateListItem])
|
||||
def list_templates(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
if is_admin(current_user):
|
||||
templates = (
|
||||
db.query(DocumentTemplate)
|
||||
.options(joinedload(DocumentTemplate.owner))
|
||||
.order_by(DocumentTemplate.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
templates = (
|
||||
accessible_templates_query(db, current_user)
|
||||
.options(joinedload(DocumentTemplate.owner))
|
||||
.order_by(DocumentTemplate.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_template_to_list_item(t, current_user) for t in templates]
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=DocumentTemplateResponse)
|
||||
def get_template(
|
||||
template_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
template = accessible_template(db, current_user, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
return _template_to_response(template, current_user)
|
||||
|
||||
|
||||
@router.get("/{template_id}/download")
|
||||
def download_template_source(
|
||||
template_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
template = accessible_template(db, current_user, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
if not os.path.exists(template.file_path):
|
||||
raise HTTPException(status_code=404, detail="Source file not found")
|
||||
|
||||
return FileResponse(
|
||||
template.file_path,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
filename=template.original_filename,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{template_id}", response_model=DocumentTemplateResponse)
|
||||
def update_template(
|
||||
template_id: int,
|
||||
update: TemplateUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
template = manageable_template(db, current_user, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
for field, value in update.model_dump(exclude_unset=True).items():
|
||||
setattr(template, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
return _template_to_response(template, current_user)
|
||||
|
||||
|
||||
@router.post("", response_model=DocumentTemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_template(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(...),
|
||||
description: str | None = Form(None),
|
||||
is_public: bool = Form(False),
|
||||
):
|
||||
if not file.filename or not file.filename.endswith(".docx"):
|
||||
raise HTTPException(status_code=400, detail="Only .docx files are supported")
|
||||
|
||||
os.makedirs(settings.upload_dir, exist_ok=True)
|
||||
unique_name = f"{uuid.uuid4().hex}_{file.filename}"
|
||||
file_path = os.path.join(settings.upload_dir, unique_name)
|
||||
|
||||
content = await file.read()
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
parsed_vars = parse_docx_template(file_path)
|
||||
|
||||
template = DocumentTemplate(
|
||||
name=name,
|
||||
description=description,
|
||||
original_filename=file.filename,
|
||||
file_path=file_path,
|
||||
owner_id=current_user.id,
|
||||
is_public=is_public,
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
for pv in parsed_vars:
|
||||
db.add(
|
||||
TemplateVariable(
|
||||
template_id=template.id,
|
||||
name=pv.name,
|
||||
label=pv.label,
|
||||
field_type=pv.field_type,
|
||||
default_value=pv.default_value,
|
||||
is_required=pv.is_required,
|
||||
order=pv.order,
|
||||
parent_variable=pv.parent_variable,
|
||||
style_params=json.dumps(pv.style_params) if pv.style_params else None,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
return _template_to_response(template, current_user)
|
||||
|
||||
|
||||
@router.put("/{template_id}/variables/{variable_id}", response_model=TemplateVariableResponse)
|
||||
def update_variable(
|
||||
template_id: int,
|
||||
variable_id: int,
|
||||
update: TemplateVariableUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
template = manageable_template(db, current_user, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
variable = db.query(TemplateVariable).filter(
|
||||
TemplateVariable.id == variable_id,
|
||||
TemplateVariable.template_id == template_id,
|
||||
).first()
|
||||
if not variable:
|
||||
raise HTTPException(status_code=404, detail="Variable not found")
|
||||
|
||||
for field, value in update.model_dump(exclude_unset=True).items():
|
||||
setattr(variable, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(variable)
|
||||
|
||||
style = None
|
||||
if variable.style_params:
|
||||
try:
|
||||
style = json.loads(variable.style_params)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return TemplateVariableResponse(
|
||||
id=variable.id,
|
||||
name=variable.name,
|
||||
label=variable.label,
|
||||
field_type=variable.field_type,
|
||||
default_value=variable.default_value,
|
||||
is_required=variable.is_required,
|
||||
order=variable.order,
|
||||
parent_variable=variable.parent_variable,
|
||||
style_params=style,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_template(
|
||||
template_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
template = manageable_template(db, current_user, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
if os.path.exists(template.file_path):
|
||||
os.remove(template.file_path)
|
||||
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
122
backend/app/routers/users.py
Normal file
122
backend/app/routers/users.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_admin, get_current_user, get_password_hash
|
||||
from app.database import get_db
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas import AdminUserCreate, UserResponse, UserUpdate
|
||||
from app.services.user_cleanup import cleanup_user_files
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
def _count_admins(db: Session) -> int:
|
||||
return db.query(User).filter(User.role == UserRole.admin, User.is_active == True).count()
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserResponse])
|
||||
def list_users(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_admin)],
|
||||
):
|
||||
return db.query(User).order_by(User.created_at).all()
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
user_data: AdminUserCreate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_admin)],
|
||||
):
|
||||
from app.auth import get_user_by_email, get_user_by_username
|
||||
|
||||
if get_user_by_email(db, user_data.email):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
if get_user_by_username(db, user_data.username):
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
role=user_data.role,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
update: UserUpdate,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_admin)],
|
||||
):
|
||||
from app.auth import get_user_by_email
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user.id == current_user.id:
|
||||
if update.role is not None and update.role != UserRole.admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot demote yourself")
|
||||
if update.is_active is False:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
||||
|
||||
if update.role is not None and update.role != UserRole.admin and user.role == UserRole.admin:
|
||||
if _count_admins(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
|
||||
|
||||
if update.is_active is False and user.role == UserRole.admin:
|
||||
if _count_admins(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate the last admin")
|
||||
|
||||
if update.email is not None and update.email != user.email:
|
||||
if get_user_by_email(db, update.email):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
user.email = update.email
|
||||
|
||||
if update.username is not None and update.username != user.username:
|
||||
from app.auth import get_user_by_username
|
||||
|
||||
if get_user_by_username(db, update.username):
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
user.username = update.username
|
||||
|
||||
if update.role is not None:
|
||||
user.role = update.role
|
||||
if update.is_active is not None:
|
||||
user.is_active = update.is_active
|
||||
if update.password:
|
||||
user.hashed_password = get_password_hash(update.password)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_admin)],
|
||||
):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
if user.role == UserRole.admin and _count_admins(db) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
|
||||
|
||||
cleanup_user_files(db, user)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
179
backend/app/schemas.py
Normal file
179
backend/app/schemas.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
username: str = Field(min_length=3, max_length=100)
|
||||
password: str = Field(min_length=6)
|
||||
confirm_password: str = Field(min_length=6)
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
message: str
|
||||
email: str
|
||||
|
||||
|
||||
class ActivateAccountRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str = Field(min_length=4, max_length=10)
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str = Field(min_length=4, max_length=10)
|
||||
password: str = Field(min_length=6)
|
||||
confirm_password: str = Field(min_length=6)
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class AdminUserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
username: str = Field(min_length=3, max_length=100)
|
||||
password: str = Field(min_length=6)
|
||||
role: UserRole = UserRole.user
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: EmailStr | None = None
|
||||
username: str | None = Field(default=None, min_length=3, max_length=100)
|
||||
role: UserRole | None = None
|
||||
is_active: bool | None = None
|
||||
password: str | None = Field(default=None, min_length=6)
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
username: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class StyleParams(BaseModel):
|
||||
font_name: str | None = None
|
||||
font_size: float | None = None
|
||||
bold: bool = False
|
||||
italic: bool = False
|
||||
underline: bool = False
|
||||
color: str | None = None
|
||||
alignment: str | None = None
|
||||
background_color: str | None = None
|
||||
border_style: str | None = None
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
table_style: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TemplateVariableResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
label: str
|
||||
field_type: str
|
||||
default_value: str | None = None
|
||||
is_required: bool
|
||||
order: int
|
||||
parent_variable: str | None = None
|
||||
style_params: dict[str, Any] | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TemplateVariableUpdate(BaseModel):
|
||||
label: str | None = None
|
||||
field_type: str | None = None
|
||||
default_value: str | None = None
|
||||
is_required: bool | None = None
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_public: bool | None = None
|
||||
|
||||
|
||||
class DocumentTemplateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
original_filename: str
|
||||
owner_id: int
|
||||
owner_username: str | None = None
|
||||
is_public: bool
|
||||
is_owner: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
variables: list[TemplateVariableResponse] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DocumentTemplateListItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
original_filename: str
|
||||
owner_id: int
|
||||
owner_username: str | None = None
|
||||
is_public: bool
|
||||
is_owner: bool = False
|
||||
created_at: datetime
|
||||
variable_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class FilledDocumentCreate(BaseModel):
|
||||
template_id: int
|
||||
name: str
|
||||
field_data: dict[str, Any]
|
||||
|
||||
|
||||
class FilledDocumentUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
field_data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FilledDocumentResponse(BaseModel):
|
||||
id: int
|
||||
template_id: int
|
||||
owner_id: int
|
||||
owner_username: str | None = None
|
||||
name: str
|
||||
field_data: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PreviewResponse(BaseModel):
|
||||
html: str
|
||||
field_data: dict[str, Any]
|
||||
32
backend/app/seed.py
Normal file
32
backend/app/seed.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_password_hash, get_user_by_email, get_user_by_username
|
||||
from app.config import settings
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
|
||||
def seed_admin_user(db: Session) -> None:
|
||||
"""Create initial admin from .env if no user with that username exists."""
|
||||
if not settings.admin_username or not settings.admin_password:
|
||||
return
|
||||
|
||||
existing = get_user_by_username(db, settings.admin_username)
|
||||
if existing:
|
||||
if existing.role != UserRole.admin:
|
||||
existing.role = UserRole.admin
|
||||
db.commit()
|
||||
return
|
||||
|
||||
email = settings.admin_email or f"{settings.admin_username}@localhost"
|
||||
if get_user_by_email(db, email):
|
||||
email = f"{settings.admin_username}.admin@localhost"
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
username=settings.admin_username,
|
||||
hashed_password=get_password_hash(settings.admin_password),
|
||||
role=UserRole.admin,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
66
backend/app/services/access.py
Normal file
66
backend/app/services/access.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Query, Session
|
||||
|
||||
from app.models.document import FilledDocument
|
||||
from app.models.template import DocumentTemplate
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
|
||||
def is_admin(user: User) -> bool:
|
||||
return user.role == UserRole.admin
|
||||
|
||||
|
||||
def can_access_template(user: User, template: DocumentTemplate) -> bool:
|
||||
return (
|
||||
is_admin(user)
|
||||
or template.owner_id == user.id
|
||||
or template.is_public
|
||||
)
|
||||
|
||||
|
||||
def can_manage_template(user: User, template: DocumentTemplate) -> bool:
|
||||
return is_admin(user) or template.owner_id == user.id
|
||||
|
||||
|
||||
def can_access_document(user: User, document: FilledDocument) -> bool:
|
||||
return is_admin(user) or document.owner_id == user.id
|
||||
|
||||
|
||||
def accessible_templates_query(db: Session, user: User) -> Query:
|
||||
return db.query(DocumentTemplate).filter(
|
||||
or_(
|
||||
DocumentTemplate.owner_id == user.id,
|
||||
DocumentTemplate.is_public == True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def accessible_template(
|
||||
db: Session, user: User, template_id: int
|
||||
) -> DocumentTemplate | None:
|
||||
template = db.query(DocumentTemplate).filter(
|
||||
DocumentTemplate.id == template_id
|
||||
).first()
|
||||
if template and can_access_template(user, template):
|
||||
return template
|
||||
return None
|
||||
|
||||
|
||||
def manageable_template(
|
||||
db: Session, user: User, template_id: int
|
||||
) -> DocumentTemplate | None:
|
||||
template = db.query(DocumentTemplate).filter(
|
||||
DocumentTemplate.id == template_id
|
||||
).first()
|
||||
if template and can_manage_template(user, template):
|
||||
return template
|
||||
return None
|
||||
|
||||
|
||||
def accessible_document(
|
||||
db: Session, user: User, document_id: int
|
||||
) -> FilledDocument | None:
|
||||
doc = db.query(FilledDocument).filter(FilledDocument.id == document_id).first()
|
||||
if doc and can_access_document(user, doc):
|
||||
return doc
|
||||
return None
|
||||
377
backend/app/services/docx_parser.py
Normal file
377
backend/app/services/docx_parser.py
Normal file
@@ -0,0 +1,377 @@
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Pt
|
||||
from docx.table import Table
|
||||
from docx.text.paragraph import Paragraph
|
||||
from jinja2 import Environment, meta
|
||||
|
||||
|
||||
JINJA_VAR_PATTERN = re.compile(r"\{\{[^{}]*?\}\}")
|
||||
JINJA_BLOCK_PATTERN = re.compile(r"\{%[^{}]*?%\}")
|
||||
TABLE_ROW_LOOP_PATTERN = re.compile(
|
||||
r"\{%\s*tr\s+for\s+(\w+)\s+in\s+(\w+)\s*%\}", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedVariable:
|
||||
name: str
|
||||
label: str
|
||||
field_type: str
|
||||
order: int
|
||||
parent_variable: str | None = None
|
||||
style_params: dict[str, Any] = field(default_factory=dict)
|
||||
default_value: str | None = None
|
||||
is_required: bool = True
|
||||
|
||||
|
||||
def _extract_run_style(run) -> dict[str, Any]:
|
||||
style: dict[str, Any] = {}
|
||||
if run.font.name:
|
||||
style["font_name"] = run.font.name
|
||||
if run.font.size:
|
||||
style["font_size"] = run.font.size.pt
|
||||
if run.bold:
|
||||
style["bold"] = True
|
||||
if run.italic:
|
||||
style["italic"] = True
|
||||
if run.underline:
|
||||
style["underline"] = True
|
||||
if run.font.color and run.font.color.rgb:
|
||||
style["color"] = str(run.font.color.rgb)
|
||||
return style
|
||||
|
||||
|
||||
def _alignment_to_str(alignment) -> str | None:
|
||||
if alignment is None:
|
||||
return None
|
||||
mapping = {
|
||||
WD_ALIGN_PARAGRAPH.LEFT: "left",
|
||||
WD_ALIGN_PARAGRAPH.CENTER: "center",
|
||||
WD_ALIGN_PARAGRAPH.RIGHT: "right",
|
||||
WD_ALIGN_PARAGRAPH.JUSTIFY: "justify",
|
||||
}
|
||||
return mapping.get(alignment)
|
||||
|
||||
|
||||
def _get_paragraph_alignment_str(paragraph: Paragraph) -> str | None:
|
||||
"""Read paragraph alignment, including Word values like 'start'/'end'."""
|
||||
try:
|
||||
alignment = paragraph.alignment
|
||||
if alignment is not None:
|
||||
return _alignment_to_str(alignment)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
p_pr = paragraph._p.pPr
|
||||
if p_pr is not None:
|
||||
jc = p_pr.find(qn("w:jc"))
|
||||
if jc is not None:
|
||||
val = jc.get(qn("w:val"))
|
||||
xml_map = {
|
||||
"left": "left",
|
||||
"right": "right",
|
||||
"center": "center",
|
||||
"both": "justify",
|
||||
"justify": "justify",
|
||||
"start": "left",
|
||||
"end": "right",
|
||||
"distribute": "justify",
|
||||
}
|
||||
return xml_map.get(val)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_paragraph_style(paragraph: Paragraph) -> dict[str, Any]:
|
||||
style: dict[str, Any] = {}
|
||||
alignment = _get_paragraph_alignment_str(paragraph)
|
||||
if alignment is not None:
|
||||
style["alignment"] = alignment
|
||||
if paragraph.runs:
|
||||
run_style = _extract_run_style(paragraph.runs[0])
|
||||
style.update(run_style)
|
||||
return style
|
||||
|
||||
|
||||
def _extract_cell_style(cell) -> dict[str, Any]:
|
||||
style: dict[str, Any] = {}
|
||||
if cell.paragraphs:
|
||||
style.update(_extract_paragraph_style(cell.paragraphs[0]))
|
||||
if cell.width:
|
||||
style["width"] = cell.width.pt if hasattr(cell.width, "pt") else None
|
||||
|
||||
tc = cell._tc
|
||||
tc_pr = tc.tcPr
|
||||
if tc_pr is not None:
|
||||
shd = tc_pr.find(qn("w:shd"))
|
||||
if shd is not None and shd.get(qn("w:fill")):
|
||||
style["background_color"] = shd.get(qn("w:fill"))
|
||||
return style
|
||||
|
||||
|
||||
def _extract_table_style(table: Table) -> dict[str, Any]:
|
||||
style: dict[str, Any] = {"rows": len(table.rows), "cols": len(table.columns)}
|
||||
if table.rows:
|
||||
style["row_styles"] = [
|
||||
[_extract_cell_style(cell) for cell in row.cells]
|
||||
for row in table.rows
|
||||
]
|
||||
return style
|
||||
|
||||
|
||||
def _get_text_from_element(element) -> str:
|
||||
if isinstance(element, Paragraph):
|
||||
return element.text
|
||||
if isinstance(element, Table):
|
||||
parts = []
|
||||
for row in element.rows:
|
||||
for cell in row.cells:
|
||||
for p in cell.paragraphs:
|
||||
parts.append(p.text)
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
DOTTED_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\.(\w+)\s*\}\}")
|
||||
|
||||
|
||||
def _find_dotted_variables_in_text(
|
||||
text: str, table_loops: dict[str, str]
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Return list of (field_name, parent_list_var, loop_item_var)."""
|
||||
results: list[tuple[str, str, str]] = []
|
||||
for match in DOTTED_VAR_PATTERN.finditer(text):
|
||||
item_var, field_name = match.groups()
|
||||
parent = table_loops.get(item_var)
|
||||
if parent:
|
||||
results.append((field_name, parent, item_var))
|
||||
return results
|
||||
|
||||
|
||||
MALFORMED_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\s*\}\}?")
|
||||
|
||||
|
||||
def _find_jinja_variables_in_text(text: str) -> list[str]:
|
||||
names: set[str] = set()
|
||||
env = Environment()
|
||||
try:
|
||||
ast = env.parse(text)
|
||||
names.update(meta.find_undeclared_variables(ast))
|
||||
except Exception:
|
||||
pass
|
||||
names.update(re.findall(r"\{\{\s*(\w+)\s*\}\}", text))
|
||||
names.update(MALFORMED_VAR_PATTERN.findall(text))
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def _detect_field_type(var_name: str, text: str, in_table: bool, is_loop_item: bool) -> str:
|
||||
if is_loop_item:
|
||||
return "table_cell"
|
||||
if in_table:
|
||||
return "table_cell"
|
||||
lower_text = text.lower()
|
||||
if any(kw in lower_text for kw in ["description", "comment", "notes", "textarea"]):
|
||||
return "textarea"
|
||||
if any(kw in var_name.lower() for kw in ["date", "дата"]):
|
||||
return "date"
|
||||
if any(kw in var_name.lower() for kw in ["amount", "price", "sum", "qty", "count", "number"]):
|
||||
return "number"
|
||||
return "text"
|
||||
|
||||
|
||||
def _humanize(name: str) -> str:
|
||||
return name.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
||||
def parse_docx_template(file_path: str) -> list[ParsedVariable]:
|
||||
doc = Document(file_path)
|
||||
variables: list[ParsedVariable] = []
|
||||
seen: set[str] = set()
|
||||
order = 0
|
||||
|
||||
table_loops: dict[str, str] = {}
|
||||
full_text_parts: list[str] = []
|
||||
|
||||
for element in doc.element.body:
|
||||
tag = element.tag.split("}")[-1]
|
||||
if tag == "p":
|
||||
para = Paragraph(element, doc)
|
||||
full_text_parts.append(para.text)
|
||||
text = para.text
|
||||
for match in TABLE_ROW_LOOP_PATTERN.finditer(text):
|
||||
item_var, list_var = match.groups()
|
||||
table_loops[item_var] = list_var
|
||||
elif tag == "tbl":
|
||||
table = Table(element, doc)
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for p in cell.paragraphs:
|
||||
full_text_parts.append(p.text)
|
||||
text = p.text
|
||||
for match in TABLE_ROW_LOOP_PATTERN.finditer(text):
|
||||
item_var, list_var = match.groups()
|
||||
table_loops[item_var] = list_var
|
||||
|
||||
full_text = "\n".join(full_text_parts)
|
||||
|
||||
dotted_fields: dict[str, tuple[str, str]] = {}
|
||||
for text_part in full_text_parts:
|
||||
for field_name, parent, item_var in _find_dotted_variables_in_text(
|
||||
text_part, table_loops
|
||||
):
|
||||
key = f"{parent}.{field_name}"
|
||||
if key not in dotted_fields:
|
||||
dotted_fields[key] = (field_name, parent)
|
||||
|
||||
for list_var in sorted(set(table_loops.values())):
|
||||
if list_var not in seen:
|
||||
seen.add(list_var)
|
||||
variables.append(
|
||||
ParsedVariable(
|
||||
name=list_var,
|
||||
label=_humanize(list_var),
|
||||
field_type="table_row",
|
||||
order=order,
|
||||
style_params={"is_repeating_table": True},
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
for key, (field_name, parent) in sorted(dotted_fields.items()):
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
table_style = _find_table_style_for_variable(doc, field_name)
|
||||
field_type = _detect_field_type(field_name, field_name, True, True)
|
||||
variables.append(
|
||||
ParsedVariable(
|
||||
name=field_name,
|
||||
label=_humanize(field_name),
|
||||
field_type=field_type,
|
||||
order=order,
|
||||
parent_variable=parent,
|
||||
style_params=table_style,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
for item_var in table_loops:
|
||||
seen.add(item_var)
|
||||
|
||||
for element in doc.element.body:
|
||||
tag = element.tag.split("}")[-1]
|
||||
in_table = tag == "tbl"
|
||||
|
||||
if tag == "p":
|
||||
para = Paragraph(element, doc)
|
||||
text = para.text
|
||||
var_names = _find_jinja_variables_in_text(text)
|
||||
para_style = _extract_paragraph_style(para)
|
||||
for var_name in var_names:
|
||||
if var_name in seen or var_name in table_loops:
|
||||
continue
|
||||
seen.add(var_name)
|
||||
field_type = _detect_field_type(var_name, text, False, False)
|
||||
variables.append(
|
||||
ParsedVariable(
|
||||
name=var_name,
|
||||
label=_humanize(var_name),
|
||||
field_type=field_type,
|
||||
order=order,
|
||||
style_params=para_style,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
elif tag == "tbl":
|
||||
table = Table(element, doc)
|
||||
table_style = _extract_table_style(table)
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for p in cell.paragraphs:
|
||||
text = p.text
|
||||
var_names = _find_jinja_variables_in_text(text)
|
||||
cell_style = _extract_cell_style(cell)
|
||||
cell_style["table_style"] = table_style
|
||||
for var_name in var_names:
|
||||
if var_name in seen or var_name in table_loops:
|
||||
continue
|
||||
if any(
|
||||
f"{table_loops[iv]}.{fn}" in seen
|
||||
for iv, fn in [
|
||||
(m.group(1), m.group(2))
|
||||
for m in DOTTED_VAR_PATTERN.finditer(text)
|
||||
]
|
||||
if iv in table_loops
|
||||
):
|
||||
continue
|
||||
seen.add(var_name)
|
||||
field_type = _detect_field_type(var_name, text, True, False)
|
||||
variables.append(
|
||||
ParsedVariable(
|
||||
name=var_name,
|
||||
label=_humanize(var_name),
|
||||
field_type=field_type,
|
||||
order=order,
|
||||
style_params=cell_style,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
env = Environment()
|
||||
try:
|
||||
ast = env.parse(full_text)
|
||||
all_vars = meta.find_undeclared_variables(ast)
|
||||
for var_name in sorted(all_vars):
|
||||
if var_name not in seen:
|
||||
seen.add(var_name)
|
||||
variables.append(
|
||||
ParsedVariable(
|
||||
name=var_name,
|
||||
label=_humanize(var_name),
|
||||
field_type="text",
|
||||
order=order,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return variables
|
||||
|
||||
|
||||
def _find_table_style_for_variable(doc: Document, var_name: str) -> dict[str, Any]:
|
||||
for element in doc.element.body:
|
||||
tag = element.tag.split("}")[-1]
|
||||
if tag != "tbl":
|
||||
continue
|
||||
table = Table(element, doc)
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for p in cell.paragraphs:
|
||||
if var_name in p.text or f".{var_name}" in p.text:
|
||||
cell_style = _extract_cell_style(cell)
|
||||
cell_style["table_style"] = _extract_table_style(table)
|
||||
return cell_style
|
||||
return {}
|
||||
|
||||
|
||||
def variables_to_json(variables: list[ParsedVariable]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": v.name,
|
||||
"label": v.label,
|
||||
"field_type": v.field_type,
|
||||
"order": v.order,
|
||||
"parent_variable": v.parent_variable,
|
||||
"style_params": v.style_params,
|
||||
"default_value": v.default_value,
|
||||
"is_required": v.is_required,
|
||||
}
|
||||
for v in variables
|
||||
]
|
||||
353
backend/app/services/docx_renderer.py
Normal file
353
backend/app/services/docx_renderer.py
Normal file
@@ -0,0 +1,353 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from html import escape
|
||||
from typing import Any
|
||||
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Pt, RGBColor
|
||||
from docx.table import Table, _Cell
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
from app.services.docx_parser import _get_paragraph_alignment_str
|
||||
|
||||
TABLE_ROW_LOOP_PATTERN = re.compile(
|
||||
r"\{%\s*tr\s+for\s+(\w+)\s+in\s+(\w+)\s*%\}", re.IGNORECASE
|
||||
)
|
||||
TABLE_ROW_END_PATTERN = re.compile(r"\{%\s*tr\s+endfor\s*%\}", re.IGNORECASE)
|
||||
DOTTED_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\.(\w+)\s*\}\}")
|
||||
SIMPLE_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
||||
TOLERANT_VAR_PATTERN = re.compile(r"\{\{\s*(\w+)\s*\}\}?")
|
||||
|
||||
|
||||
def _prepare_context(field_data: dict[str, Any]) -> dict[str, Any]:
|
||||
return dict(field_data)
|
||||
|
||||
|
||||
def _clean_jinja_markers(text: str) -> str:
|
||||
text = TABLE_ROW_LOOP_PATTERN.sub("", text)
|
||||
text = TABLE_ROW_END_PATTERN.sub("", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _replace_variables_in_text(text: str, context: dict[str, Any]) -> str:
|
||||
def replacer(match: re.Match) -> str:
|
||||
name = match.group(1)
|
||||
value = context.get(name, "")
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
text = SIMPLE_VAR_PATTERN.sub(replacer, text)
|
||||
if "{{" in text:
|
||||
text = TOLERANT_VAR_PATTERN.sub(replacer, text)
|
||||
return text
|
||||
|
||||
|
||||
def _replace_simple_vars(text: str, context: dict[str, Any]) -> str:
|
||||
return _replace_variables_in_text(text, context)
|
||||
|
||||
|
||||
def _replace_vars_in_paragraph(paragraph, context: dict[str, Any]) -> None:
|
||||
"""Replace Jinja variables while preserving run formatting."""
|
||||
if "{{" not in paragraph.text:
|
||||
return
|
||||
|
||||
full_text = "".join(run.text for run in paragraph.runs)
|
||||
new_text = _replace_variables_in_text(full_text, context)
|
||||
if new_text == full_text:
|
||||
return
|
||||
|
||||
if paragraph.runs:
|
||||
paragraph.runs[0].text = new_text
|
||||
for run in paragraph.runs[1:]:
|
||||
run.text = ""
|
||||
else:
|
||||
paragraph.add_run(new_text)
|
||||
|
||||
|
||||
def _expand_table_rows(doc: Document, context: dict[str, Any]) -> None:
|
||||
for table in doc.tables:
|
||||
rows_to_process: list[tuple[int, str, str, list[str]]] = []
|
||||
|
||||
for row_idx, row in enumerate(table.rows):
|
||||
row_text = " ".join(
|
||||
p.text for cell in row.cells for p in cell.paragraphs
|
||||
)
|
||||
loop_match = TABLE_ROW_LOOP_PATTERN.search(row_text)
|
||||
if not loop_match:
|
||||
continue
|
||||
|
||||
item_var, list_var = loop_match.groups()
|
||||
field_names: list[str] = []
|
||||
for cell in row.cells:
|
||||
for p in cell.paragraphs:
|
||||
for m in DOTTED_VAR_PATTERN.finditer(p.text):
|
||||
if m.group(1) == item_var:
|
||||
field = m.group(2)
|
||||
if field not in field_names:
|
||||
field_names.append(field)
|
||||
|
||||
rows_to_process.append((row_idx, item_var, list_var, field_names))
|
||||
|
||||
for row_idx, item_var, list_var, field_names in reversed(rows_to_process):
|
||||
template_row = table.rows[row_idx]
|
||||
items = context.get(list_var, [])
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
|
||||
if not items:
|
||||
for cell in template_row.cells:
|
||||
for p in cell.paragraphs:
|
||||
_replace_vars_in_paragraph(p, context)
|
||||
cleaned = _clean_jinja_markers(p.text)
|
||||
if cleaned != p.text and p.runs:
|
||||
p.runs[0].text = cleaned
|
||||
for run in p.runs[1:]:
|
||||
run.text = ""
|
||||
continue
|
||||
|
||||
template_cells = [
|
||||
[_clean_jinja_markers(p.text) for p in cell.paragraphs]
|
||||
for cell in template_row.cells
|
||||
]
|
||||
|
||||
for item in items:
|
||||
new_row = table.add_row()
|
||||
for cell_idx, cell in enumerate(new_row.cells):
|
||||
if cell_idx < len(template_cells):
|
||||
template_text = (
|
||||
template_cells[cell_idx][0]
|
||||
if template_cells[cell_idx]
|
||||
else ""
|
||||
)
|
||||
result = template_text
|
||||
for field in field_names:
|
||||
placeholder = f"{{{{ {item_var}.{field} }}}}"
|
||||
value = ""
|
||||
if isinstance(item, dict):
|
||||
value = item.get(field, "")
|
||||
result = result.replace(placeholder, str(value))
|
||||
result = DOTTED_VAR_PATTERN.sub(
|
||||
lambda m, it=item: str(it.get(m.group(2), ""))
|
||||
if isinstance(it, dict) and m.group(1) == item_var
|
||||
else m.group(0),
|
||||
result,
|
||||
)
|
||||
if cell.paragraphs:
|
||||
_replace_vars_in_paragraph(cell.paragraphs[0], context)
|
||||
if cell.paragraphs[0].text != result and cell.paragraphs[0].runs:
|
||||
cell.paragraphs[0].runs[0].text = result
|
||||
for run in cell.paragraphs[0].runs[1:]:
|
||||
run.text = ""
|
||||
else:
|
||||
cell.text = result
|
||||
|
||||
tbl = table._tbl
|
||||
tr = template_row._tr
|
||||
tbl.remove(tr)
|
||||
|
||||
|
||||
def _replace_paragraph_vars(doc: Document, context: dict[str, Any]) -> None:
|
||||
for para in doc.paragraphs:
|
||||
_replace_vars_in_paragraph(para, context)
|
||||
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
if "{{" in para.text and not DOTTED_VAR_PATTERN.search(para.text):
|
||||
_replace_vars_in_paragraph(para, context)
|
||||
|
||||
|
||||
def render_docx(template_path: str, field_data: dict[str, Any], output_path: str) -> str:
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
context = _prepare_context(field_data)
|
||||
|
||||
try:
|
||||
tpl = DocxTemplate(template_path)
|
||||
tpl.render(context)
|
||||
tpl.save(output_path)
|
||||
return output_path
|
||||
except Exception:
|
||||
doc = Document(template_path)
|
||||
_expand_table_rows(doc, context)
|
||||
_replace_paragraph_vars(doc, context)
|
||||
doc.save(output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
def _apply_style_to_run(run, style: dict[str, Any]) -> None:
|
||||
if style.get("font_name"):
|
||||
run.font.name = style["font_name"]
|
||||
if style.get("font_size"):
|
||||
run.font.size = Pt(style["font_size"])
|
||||
if style.get("bold"):
|
||||
run.bold = True
|
||||
if style.get("italic"):
|
||||
run.italic = True
|
||||
if style.get("underline"):
|
||||
run.underline = True
|
||||
if style.get("color"):
|
||||
try:
|
||||
run.font.color.rgb = RGBColor.from_string(style["color"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_to_html(run) -> str:
|
||||
text = escape(run.text)
|
||||
if not text:
|
||||
return ""
|
||||
styles: list[str] = []
|
||||
if run.bold:
|
||||
styles.append("font-weight:bold")
|
||||
if run.italic:
|
||||
styles.append("font-style:italic")
|
||||
if run.underline:
|
||||
styles.append("text-decoration:underline")
|
||||
if run.font.size:
|
||||
styles.append(f"font-size:{run.font.size.pt}pt")
|
||||
if run.font.name:
|
||||
styles.append(f"font-family:'{run.font.name}'")
|
||||
if styles:
|
||||
return f'<span style="{";".join(styles)}">{text}</span>'
|
||||
return text
|
||||
|
||||
|
||||
def _paragraph_to_html(paragraph) -> str:
|
||||
parts = [_run_to_html(run) for run in paragraph.runs]
|
||||
return "".join(parts) if any(parts) else escape(paragraph.text)
|
||||
|
||||
|
||||
def _get_cell_colspan(tc) -> int:
|
||||
tc_pr = tc.tcPr
|
||||
if tc_pr is None:
|
||||
return 1
|
||||
grid_span = tc_pr.find(qn("w:gridSpan"))
|
||||
if grid_span is not None:
|
||||
return int(grid_span.get(qn("w:val"), 1))
|
||||
return 1
|
||||
|
||||
|
||||
def _get_vmerge_state(tc) -> str | None:
|
||||
tc_pr = tc.tcPr
|
||||
if tc_pr is None:
|
||||
return None
|
||||
v_merge = tc_pr.find(qn("w:vMerge"))
|
||||
if v_merge is None:
|
||||
return None
|
||||
return v_merge.get(qn("w:val")) or "continue"
|
||||
|
||||
|
||||
def _cell_to_html(cell: _Cell) -> str:
|
||||
return "<br>".join(_paragraph_to_html(p) for p in cell.paragraphs)
|
||||
|
||||
|
||||
def _table_to_html(table: Table) -> str:
|
||||
"""Render table using actual w:tc elements (handles merged cells)."""
|
||||
rows_html: list[str] = []
|
||||
|
||||
for row in table.rows:
|
||||
tr = row._tr
|
||||
cells_html: list[str] = []
|
||||
|
||||
for tc in tr.tc_lst:
|
||||
if _get_vmerge_state(tc) == "continue":
|
||||
continue
|
||||
|
||||
cell = _Cell(tc, table)
|
||||
colspan = _get_cell_colspan(tc)
|
||||
attrs = ""
|
||||
if colspan > 1:
|
||||
attrs += f' colspan="{colspan}"'
|
||||
|
||||
cells_html.append(f"<td{attrs}>{_cell_to_html(cell)}</td>")
|
||||
|
||||
rows_html.append(f"<tr>{''.join(cells_html)}</tr>")
|
||||
|
||||
return (
|
||||
'<table class="doc-table" border="1" cellpadding="4" cellspacing="0">'
|
||||
+ "".join(rows_html)
|
||||
+ "</table>"
|
||||
)
|
||||
|
||||
|
||||
def docx_to_html(file_path: str) -> str:
|
||||
doc = Document(file_path)
|
||||
html_parts = ['<div class="doc-preview">']
|
||||
|
||||
for element in doc.element.body:
|
||||
tag = element.tag.split("}")[-1]
|
||||
if tag == "p":
|
||||
from docx.text.paragraph import Paragraph
|
||||
|
||||
para = Paragraph(element, doc)
|
||||
align = ""
|
||||
alignment = _get_paragraph_alignment_str(para)
|
||||
if alignment is not None:
|
||||
align = f' style="text-align:{alignment}"'
|
||||
text = _paragraph_to_html(para)
|
||||
html_parts.append(f"<p{align}>{text}</p>")
|
||||
elif tag == "tbl":
|
||||
table = Table(element, doc)
|
||||
html_parts.append(_table_to_html(table))
|
||||
|
||||
html_parts.append("</div>")
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
def export_to_pdf(docx_path: str, output_path: str) -> str:
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"libreoffice",
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
os.path.dirname(output_path),
|
||||
docx_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
base = os.path.splitext(os.path.basename(docx_path))[0]
|
||||
generated = os.path.join(os.path.dirname(output_path), f"{base}.pdf")
|
||||
if os.path.exists(generated) and generated != output_path:
|
||||
os.rename(generated, output_path)
|
||||
return output_path
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
return _fallback_pdf_export(docx_path, output_path)
|
||||
|
||||
|
||||
def _fallback_pdf_export(docx_path: str, output_path: str) -> str:
|
||||
html = docx_to_html(docx_path)
|
||||
full_html = f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<style>
|
||||
body {{ font-family: 'Times New Roman', serif; margin: 40px; }}
|
||||
.doc-table {{ border-collapse: collapse; width: 100%; margin: 10px 0; }}
|
||||
.doc-table td, .doc-table th {{ border: 1px solid #333; padding: 6px 10px; }}
|
||||
p {{ margin: 6px 0; }}
|
||||
</style></head><body>{html}</body></html>"""
|
||||
|
||||
try:
|
||||
import weasyprint
|
||||
|
||||
weasyprint.HTML(string=full_html).write_pdf(output_path)
|
||||
return output_path
|
||||
except ImportError:
|
||||
html_path = output_path.replace(".pdf", ".html")
|
||||
with open(html_path, "w", encoding="utf-8") as f:
|
||||
f.write(full_html)
|
||||
raise RuntimeError(
|
||||
"PDF export requires LibreOffice (libreoffice) or weasyprint. "
|
||||
f"HTML preview saved to {html_path}"
|
||||
)
|
||||
57
backend/app/services/email_service.py
Normal file
57
backend/app/services/email_service.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _send_smtp(to_email: str, subject: str, body: str) -> None:
|
||||
if not settings.smtp_host:
|
||||
logger.warning(
|
||||
"SMTP not configured. Email to %s — subject: %s\n%s",
|
||||
to_email,
|
||||
subject,
|
||||
body,
|
||||
)
|
||||
return
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from or settings.smtp_user
|
||||
msg["To"] = to_email
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
|
||||
if settings.smtp_use_tls:
|
||||
server.starttls()
|
||||
if settings.smtp_user and settings.smtp_password:
|
||||
server.login(settings.smtp_user, settings.smtp_password)
|
||||
server.sendmail(msg["From"], [to_email], msg.as_string())
|
||||
|
||||
|
||||
def send_activation_email(to_email: str, username: str, code: str) -> None:
|
||||
activate_url = f"{settings.frontend_url.rstrip('/')}/activate"
|
||||
body = (
|
||||
f"Hello {username},\n\n"
|
||||
f"Thank you for registering at Document Template Editor.\n\n"
|
||||
f"Your activation code: {code}\n\n"
|
||||
f"Enter this code at: {activate_url}\n\n"
|
||||
f"The code expires in {settings.activation_code_expire_hours} hours.\n"
|
||||
)
|
||||
_send_smtp(to_email, "Activate your account", body)
|
||||
|
||||
|
||||
def send_password_reset_email(to_email: str, username: str, code: str) -> None:
|
||||
reset_url = f"{settings.frontend_url.rstrip('/')}/reset-password"
|
||||
body = (
|
||||
f"Hello {username},\n\n"
|
||||
f"You requested a password reset.\n\n"
|
||||
f"Your reset code: {code}\n\n"
|
||||
f"Enter this code at: {reset_url}\n\n"
|
||||
f"The code expires in {settings.password_reset_code_expire_hours} hours.\n\n"
|
||||
f"If you did not request this, ignore this email.\n"
|
||||
)
|
||||
_send_smtp(to_email, "Password reset code", body)
|
||||
36
backend/app/services/user_cleanup.py
Normal file
36
backend/app/services/user_cleanup.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.document import FilledDocument
|
||||
from app.models.template import DocumentTemplate
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def cleanup_user_files(db: Session, user: User) -> None:
|
||||
"""Remove uploaded template and rendered document files for a user."""
|
||||
templates = (
|
||||
db.query(DocumentTemplate).filter(DocumentTemplate.owner_id == user.id).all()
|
||||
)
|
||||
template_ids = [t.id for t in templates]
|
||||
|
||||
for template in templates:
|
||||
if template.file_path and os.path.exists(template.file_path):
|
||||
os.remove(template.file_path)
|
||||
|
||||
documents = db.query(FilledDocument).filter(
|
||||
FilledDocument.owner_id == user.id
|
||||
).all()
|
||||
if template_ids:
|
||||
documents += (
|
||||
db.query(FilledDocument)
|
||||
.filter(FilledDocument.template_id.in_(template_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
seen_paths: set[str] = set()
|
||||
for doc in documents:
|
||||
if doc.rendered_docx_path and doc.rendered_docx_path not in seen_paths:
|
||||
seen_paths.add(doc.rendered_docx_path)
|
||||
if os.path.exists(doc.rendered_docx_path):
|
||||
os.remove(doc.rendered_docx_path)
|
||||
67
backend/app/services/verification.py
Normal file
67
backend/app/services/verification.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
from app.models.verification_token import TokenType, VerificationToken
|
||||
|
||||
|
||||
def _generate_code(length: int = 6) -> str:
|
||||
return "".join(random.choices(string.digits, k=length))
|
||||
|
||||
|
||||
def _invalidate_tokens(db: Session, user_id: int, token_type: TokenType) -> None:
|
||||
db.query(VerificationToken).filter(
|
||||
VerificationToken.user_id == user_id,
|
||||
VerificationToken.token_type == token_type,
|
||||
).delete()
|
||||
|
||||
|
||||
def create_verification_token(
|
||||
db: Session, user: User, token_type: TokenType
|
||||
) -> VerificationToken:
|
||||
_invalidate_tokens(db, user.id, token_type)
|
||||
|
||||
if token_type == TokenType.activation:
|
||||
hours = settings.activation_code_expire_hours
|
||||
else:
|
||||
hours = settings.password_reset_code_expire_hours
|
||||
|
||||
token = VerificationToken(
|
||||
user_id=user.id,
|
||||
code=_generate_code(),
|
||||
token_type=token_type,
|
||||
expires_at=datetime.utcnow() + timedelta(hours=hours),
|
||||
)
|
||||
db.add(token)
|
||||
db.flush()
|
||||
return token
|
||||
|
||||
|
||||
def verify_code(
|
||||
db: Session, email: str, code: str, token_type: TokenType
|
||||
) -> User | None:
|
||||
from app.auth import get_user_by_email
|
||||
|
||||
user = get_user_by_email(db, email)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
token = (
|
||||
db.query(VerificationToken)
|
||||
.filter(
|
||||
VerificationToken.user_id == user.id,
|
||||
VerificationToken.token_type == token_type,
|
||||
VerificationToken.code == code.strip(),
|
||||
VerificationToken.expires_at > datetime.utcnow(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
db.delete(token)
|
||||
return user
|
||||
15
backend/requirements.txt
Normal file
15
backend/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg2-binary==2.9.10
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.20
|
||||
docxtpl==0.19.0
|
||||
python-docx==1.1.2
|
||||
jinja2==3.1.4
|
||||
aiofiles==24.1.0
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
email-validator==2.2.0
|
||||
65
docker-compose.yml
Normal file
65
docker-compose.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-doceditor}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-doceditor}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-doceditor}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-doceditor} -d ${POSTGRES_DB:-doceditor}",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-doceditor}:${POSTGRES_PASSWORD:-doceditor}@db:5432/${POSTGRES_DB:-doceditor}
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
|
||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
|
||||
UPLOAD_DIR: /app/uploads
|
||||
EXPORT_DIR: /app/exports
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-noreply@example.com}
|
||||
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
|
||||
ACTIVATION_CODE_EXPIRE_HOURS: ${ACTIVATION_CODE_EXPIRE_HOURS:-24}
|
||||
PASSWORD_RESET_CODE_EXPIRE_HOURS: ${PASSWORD_RESET_CODE_EXPIRE_HOURS:-1}
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- exports_data:/app/exports
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-5173}:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
exports_data:
|
||||
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.local
|
||||
4
frontend/.gitignore
vendored
Normal file
4
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.local
|
||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Document Template Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
frontend/nginx.conf
Normal file
21
frontend/nginx.conf
Normal file
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
1774
frontend/package-lock.json
generated
Normal file
1774
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "document-template-editor-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
59
frontend/src/App.tsx
Normal file
59
frontend/src/App.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import Navbar from './components/Navbar'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import CreateTemplatePage from './pages/CreateTemplatePage'
|
||||
import DocumentDetailPage from './pages/DocumentDetailPage'
|
||||
import DocumentsPage from './pages/DocumentsPage'
|
||||
import EditDocumentPage from './pages/EditDocumentPage'
|
||||
import FillTemplatePage from './pages/FillTemplatePage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RegisterPage from './pages/RegisterPage'
|
||||
import ActivatePage from './pages/ActivatePage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import TemplateDetailPage from './pages/TemplateDetailPage'
|
||||
import TemplatesPage from './pages/TemplatesPage'
|
||||
import UsersPage from './pages/UsersPage'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return <div className="container"><p>Loading...</p></div>
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
{user && <Navbar />}
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/activate" element={<ActivatePage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/" element={<Navigate to="/templates" replace />} />
|
||||
<Route path="/templates" element={<ProtectedRoute><TemplatesPage /></ProtectedRoute>} />
|
||||
<Route path="/templates/new" element={<ProtectedRoute><CreateTemplatePage /></ProtectedRoute>} />
|
||||
<Route path="/templates/:id" element={<ProtectedRoute><TemplateDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/templates/:id/fill" element={<ProtectedRoute><FillTemplatePage /></ProtectedRoute>} />
|
||||
<Route path="/documents" element={<ProtectedRoute><DocumentsPage /></ProtectedRoute>} />
|
||||
<Route path="/documents/:id" element={<ProtectedRoute><DocumentDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/documents/:id/edit" element={<ProtectedRoute><EditDocumentPage /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><UsersPage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
276
frontend/src/api.ts
Normal file
276
frontend/src/api.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type {
|
||||
DocumentTemplate,
|
||||
FilledDocument,
|
||||
PreviewResponse,
|
||||
TemplateListItem,
|
||||
User,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('token')
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json'
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Request failed' }))
|
||||
const detail = error.detail
|
||||
const message =
|
||||
typeof detail === 'string'
|
||||
? detail
|
||||
: Array.isArray(detail)
|
||||
? detail.map((d: { msg?: string }) => d.msg).filter(Boolean).join(', ') || 'Request failed'
|
||||
: `HTTP ${response.status}`
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async login(username: string, password: string): Promise<{ access_token: string }> {
|
||||
const form = new FormData()
|
||||
form.append('username', username)
|
||||
form.append('password', password)
|
||||
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Login failed' }))
|
||||
const detail = error.detail
|
||||
const message =
|
||||
typeof detail === 'string'
|
||||
? detail
|
||||
: Array.isArray(detail)
|
||||
? detail.map((d: { msg?: string }) => d.msg).filter(Boolean).join(', ') || 'Login failed'
|
||||
: 'Login failed'
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
|
||||
async register(data: {
|
||||
email: string
|
||||
username: string
|
||||
password: string
|
||||
confirm_password: string
|
||||
}): Promise<{ message: string; email: string }> {
|
||||
return request<{ message: string; email: string }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
async activateAccount(email: string, code: string): Promise<{ message: string }> {
|
||||
return request<{ message: string }>('/auth/activate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, code }),
|
||||
})
|
||||
},
|
||||
|
||||
async resendActivation(email: string): Promise<{ message: string }> {
|
||||
return request<{ message: string }>('/auth/resend-activation', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
},
|
||||
|
||||
async forgotPassword(email: string): Promise<{ message: string }> {
|
||||
return request<{ message: string }>('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
},
|
||||
|
||||
async resetPassword(data: {
|
||||
email: string
|
||||
code: string
|
||||
password: string
|
||||
confirm_password: string
|
||||
}): Promise<{ message: string }> {
|
||||
return request<{ message: string }>('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return request<User>('/auth/me')
|
||||
},
|
||||
|
||||
async listUsers(): Promise<User[]> {
|
||||
return request<User[]>('/users')
|
||||
},
|
||||
|
||||
async createUser(data: {
|
||||
email: string
|
||||
username: string
|
||||
password: string
|
||||
role: 'user' | 'admin'
|
||||
}): Promise<User> {
|
||||
return request<User>('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
async updateUser(
|
||||
id: number,
|
||||
data: {
|
||||
email?: string
|
||||
username?: string
|
||||
role?: 'user' | 'admin'
|
||||
is_active?: boolean
|
||||
password?: string
|
||||
},
|
||||
): Promise<User> {
|
||||
return request<User>(`/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
async deleteUser(id: number): Promise<void> {
|
||||
return request<void>(`/users/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
async listTemplates(): Promise<TemplateListItem[]> {
|
||||
return request<TemplateListItem[]>('/templates')
|
||||
},
|
||||
|
||||
async getTemplate(id: number): Promise<DocumentTemplate> {
|
||||
return request<DocumentTemplate>(`/templates/${id}`)
|
||||
},
|
||||
|
||||
async createTemplate(
|
||||
file: File,
|
||||
name: string,
|
||||
description?: string,
|
||||
isPublic?: boolean,
|
||||
): Promise<DocumentTemplate> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('name', name)
|
||||
if (description) form.append('description', description)
|
||||
form.append('is_public', String(isPublic ?? false))
|
||||
|
||||
return request<DocumentTemplate>('/templates', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
},
|
||||
|
||||
async deleteTemplate(id: number): Promise<void> {
|
||||
return request<void>(`/templates/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
async updateTemplate(
|
||||
id: number,
|
||||
data: { name?: string; description?: string; is_public?: boolean },
|
||||
): Promise<DocumentTemplate> {
|
||||
return request<DocumentTemplate>(`/templates/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
getTemplateSourceUrl(templateId: number): string {
|
||||
return `${API_BASE}/templates/${templateId}/download`
|
||||
},
|
||||
|
||||
async listDocuments(): Promise<FilledDocument[]> {
|
||||
return request<FilledDocument[]>('/documents')
|
||||
},
|
||||
|
||||
async getDocument(id: number): Promise<FilledDocument> {
|
||||
return request<FilledDocument>(`/documents/${id}`)
|
||||
},
|
||||
|
||||
async createDocument(data: {
|
||||
template_id: number
|
||||
name: string
|
||||
field_data: Record<string, unknown>
|
||||
}): Promise<FilledDocument> {
|
||||
return request<FilledDocument>('/documents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
async updateDocument(
|
||||
id: number,
|
||||
data: { name?: string; field_data?: Record<string, unknown> },
|
||||
): Promise<FilledDocument> {
|
||||
return request<FilledDocument>(`/documents/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
async deleteDocument(id: number): Promise<void> {
|
||||
return request<void>(`/documents/${id}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
async previewDocument(data: {
|
||||
template_id: number
|
||||
name: string
|
||||
field_data: Record<string, unknown>
|
||||
}): Promise<PreviewResponse> {
|
||||
return request<PreviewResponse>('/documents/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
|
||||
getExportDocxUrl(documentId: number): string {
|
||||
return `${API_BASE}/documents/${documentId}/export/docx`
|
||||
},
|
||||
|
||||
getExportPdfUrl(documentId: number): string {
|
||||
return `${API_BASE}/documents/${documentId}/export/pdf`
|
||||
},
|
||||
}
|
||||
|
||||
export async function downloadWithAuth(url: string, filename: string) {
|
||||
const token = getToken()
|
||||
const response = await fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!response.ok) throw new Error('Download failed')
|
||||
const blob = await response.blob()
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
42
frontend/src/components/Navbar.tsx
Normal file
42
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, logout } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
const isActive = (path: string) =>
|
||||
location.pathname === path || location.pathname.startsWith(path + '/')
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<Link to="/" className="navbar-brand">
|
||||
Document Template Editor
|
||||
</Link>
|
||||
<div className="navbar-links">
|
||||
<Link to="/templates" className={isActive('/templates') ? 'active' : ''}>
|
||||
Templates
|
||||
</Link>
|
||||
<Link to="/documents" className={isActive('/documents') ? 'active' : ''}>
|
||||
Documents
|
||||
</Link>
|
||||
{user?.role === 'admin' && (
|
||||
<Link to="/users" className={isActive('/users') ? 'active' : ''}>
|
||||
Users
|
||||
</Link>
|
||||
)}
|
||||
{user && (
|
||||
<>
|
||||
<span>
|
||||
{user.username}{' '}
|
||||
<span className={`badge badge-${user.role}`}>{user.role}</span>
|
||||
</span>
|
||||
<button className="btn btn-secondary btn-sm" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
80
frontend/src/components/TableRowEditor.tsx
Normal file
80
frontend/src/components/TableRowEditor.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { TemplateVariable } from '../types'
|
||||
import VariableField from './VariableField'
|
||||
|
||||
interface TableRowEditorProps {
|
||||
tableVariable: TemplateVariable
|
||||
childVariables: TemplateVariable[]
|
||||
rows: Record<string, unknown>[]
|
||||
onChange: (rows: Record<string, unknown>[]) => void
|
||||
}
|
||||
|
||||
export default function TableRowEditor({
|
||||
tableVariable,
|
||||
childVariables,
|
||||
rows,
|
||||
onChange,
|
||||
}: TableRowEditorProps) {
|
||||
const addRow = () => {
|
||||
const newRow: Record<string, unknown> = {}
|
||||
childVariables.forEach((v) => {
|
||||
newRow[v.name] = v.default_value ?? ''
|
||||
})
|
||||
onChange([...rows, newRow])
|
||||
}
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
onChange(rows.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateRowField = (rowIndex: number, field: string, value: unknown) => {
|
||||
const updated = rows.map((row, i) =>
|
||||
i === rowIndex ? { ...row, [field]: value } : row,
|
||||
)
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
const tableStyle = tableVariable.style_params?.table_style as
|
||||
| { row_styles?: Record<string, unknown>[][] }
|
||||
| undefined
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label>{tableVariable.label} (repeating table rows)</label>
|
||||
{tableStyle && (
|
||||
<div className="style-hint">
|
||||
Table styles preserved from template ({tableStyle.row_styles?.length ?? 0} reference
|
||||
rows)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="table-row-editor">
|
||||
<div className="table-row-editor-header">
|
||||
<strong>Row {rowIndex + 1}</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => removeRow(rowIndex)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-row-fields">
|
||||
{childVariables.map((child) => (
|
||||
<VariableField
|
||||
key={child.id}
|
||||
variable={child}
|
||||
value={row[child.name]}
|
||||
onChange={(val) => updateRowField(rowIndex, child.name, val)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" className="btn btn-secondary" onClick={addRow}>
|
||||
+ Add Row
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
frontend/src/components/VariableField.tsx
Normal file
71
frontend/src/components/VariableField.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { TemplateVariable } from '../types'
|
||||
|
||||
interface VariableFieldProps {
|
||||
variable: TemplateVariable
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
}
|
||||
|
||||
export default function VariableField({ variable, value, onChange }: VariableFieldProps) {
|
||||
const styleHint = variable.style_params
|
||||
? [
|
||||
variable.style_params.font_name && `Font: ${variable.style_params.font_name}`,
|
||||
variable.style_params.font_size && `Size: ${variable.style_params.font_size}pt`,
|
||||
variable.style_params.bold && 'Bold',
|
||||
variable.style_params.alignment && `Align: ${variable.style_params.alignment}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: null
|
||||
|
||||
const renderInput = () => {
|
||||
switch (variable.field_type) {
|
||||
case 'textarea':
|
||||
return (
|
||||
<textarea
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={variable.is_required}
|
||||
/>
|
||||
)
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={(value as number) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : '')}
|
||||
required={variable.is_required}
|
||||
/>
|
||||
)
|
||||
case 'date':
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={variable.is_required}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required={variable.is_required}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{variable.label}
|
||||
{variable.is_required && ' *'}
|
||||
</label>
|
||||
{renderInput()}
|
||||
{styleHint && <div className="style-hint">Style: {styleHint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
frontend/src/context/AuthContext.tsx
Normal file
83
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { api } from '../api'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
register: (email: string, username: string, password: string, confirmPassword: string) => Promise<{ message: string; email: string }>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const me = await api.getMe()
|
||||
setUser(me)
|
||||
} catch {
|
||||
localStorage.removeItem('token')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [loadUser])
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const { access_token } = await api.login(username, password)
|
||||
localStorage.setItem('token', access_token)
|
||||
const me = await api.getMe()
|
||||
setUser(me)
|
||||
}
|
||||
|
||||
const register = async (
|
||||
email: string,
|
||||
username: string,
|
||||
password: string,
|
||||
confirmPassword: string,
|
||||
) => {
|
||||
return api.register({
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
confirm_password: confirmPassword,
|
||||
})
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
380
frontend/src/index.css
Normal file
380
frontend/src/index.css
Normal file
@@ -0,0 +1,380 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f4f6f9;
|
||||
--surface: #ffffff;
|
||||
--border: #dde3ec;
|
||||
--text: #1a2332;
|
||||
--muted: #5c6b7f;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--radius: 10px;
|
||||
--shadow: 0 4px 20px rgba(15, 23, 42, 0.08);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e8eef7;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d8e3f3;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fee2e2;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: 2px solid #93c5fd;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.navbar-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.navbar-links a {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.navbar-links a.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-preview {
|
||||
background: white;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.doc-preview .doc-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.doc-preview .doc-table td,
|
||||
.doc-preview .doc-table th {
|
||||
border: 1px solid #333;
|
||||
padding: 4px 8px;
|
||||
vertical-align: top;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.style-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.table-row-editor {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.table-row-editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-row-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-card p {
|
||||
margin: 0 0 24px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #e8eef7;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.step.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step.done {
|
||||
background: #dcfce7;
|
||||
color: var(--success);
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
92
frontend/src/pages/ActivatePage.tsx
Normal file
92
frontend/src/pages/ActivatePage.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function ActivatePage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const initialEmail = (location.state as { email?: string } | null)?.email ?? ''
|
||||
|
||||
const [email, setEmail] = useState(initialEmail)
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleActivate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await api.activateAccount(email, code)
|
||||
setSuccess(result.message)
|
||||
setTimeout(() => navigate('/login'), 1500)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Activation failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
try {
|
||||
const result = await api.resendActivation(email)
|
||||
setSuccess(result.message)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to resend code')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="card auth-card">
|
||||
<h1>Activate Account</h1>
|
||||
<p>Enter the activation code sent to your email.</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<form onSubmit={handleActivate}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Activation Code</label>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
placeholder="6-digit code"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Activate'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%', marginTop: 12 }}
|
||||
onClick={handleResend}
|
||||
disabled={!email}
|
||||
>
|
||||
Resend Code
|
||||
</button>
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/login">Back to login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
frontend/src/pages/CreateTemplatePage.tsx
Normal file
105
frontend/src/pages/CreateTemplatePage.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function CreateTemplatePage() {
|
||||
const navigate = useNavigate()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [isPublic, setIsPublic] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!file) {
|
||||
setError('Please select a .docx file')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const template = await api.createTemplate(
|
||||
file,
|
||||
name,
|
||||
description || undefined,
|
||||
isPublic,
|
||||
)
|
||||
navigate(`/templates/${template.id}/fill`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<h1>Upload Template</h1>
|
||||
</div>
|
||||
|
||||
<div className="steps">
|
||||
<div className="step active">1. Upload .docx</div>
|
||||
<div className="step">2. Fill Variables</div>
|
||||
<div className="step">3. Preview & Export</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 600 }}>
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Template Name *</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>.docx File with Jinja2 Variables *</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".docx"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
<div className="style-hint" style={{ marginTop: 8 }}>
|
||||
Use {'{{ variable }}'} for text fields. For repeating table rows use{' '}
|
||||
{'{%tr for item in items %}'} ... {'{%tr endfor %}'}.
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
style={{ width: 'auto', marginRight: 8 }}
|
||||
/>
|
||||
Make template public (visible to all users)
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Parsing...' : 'Upload & Continue'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate('/templates')}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
frontend/src/pages/DocumentDetailPage.tsx
Normal file
145
frontend/src/pages/DocumentDetailPage.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api, downloadWithAuth } from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { DocumentTemplate, FilledDocument } from '../types'
|
||||
|
||||
export default function DocumentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const [document, setDocument] = useState<FilledDocument | null>(null)
|
||||
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const doc = await api.getDocument(Number(id))
|
||||
setDocument(doc)
|
||||
const t = await api.getTemplate(doc.template_id)
|
||||
setTemplate(t)
|
||||
const preview = await api.previewDocument({
|
||||
template_id: doc.template_id,
|
||||
name: doc.name,
|
||||
field_data: doc.field_data,
|
||||
})
|
||||
setPreviewHtml(preview.html)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
const handleExportDocx = async () => {
|
||||
if (!document) return
|
||||
try {
|
||||
await downloadWithAuth(
|
||||
api.getExportDocxUrl(document.id),
|
||||
`${document.name}.docx`,
|
||||
)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Export failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPdf = async () => {
|
||||
if (!document) return
|
||||
try {
|
||||
await downloadWithAuth(
|
||||
api.getExportPdfUrl(document.id),
|
||||
`${document.name}.pdf`,
|
||||
)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'PDF export failed. Install LibreOffice for PDF support.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!document || !confirm(`Delete document "${document.name}"?`)) return
|
||||
try {
|
||||
await api.deleteDocument(document.id)
|
||||
navigate('/documents')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
const canManage =
|
||||
user?.role === 'admin' || document?.owner_id === user?.id
|
||||
|
||||
if (loading) return <div className="container"><p>Loading...</p></div>
|
||||
if (!document) return <div className="container"><div className="error">{error}</div></div>
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>{document.name}</h1>
|
||||
{template && (
|
||||
<div className="style-hint">
|
||||
From template: {template.name}
|
||||
{document.owner_username && ` · Owner: ${document.owner_username}`}
|
||||
{' · '}Saved {new Date(document.created_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" onClick={handleExportDocx}>
|
||||
Export DOCX
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleExportPdf}>
|
||||
Export PDF
|
||||
</button>
|
||||
<Link to={`/documents/${document.id}/edit`} className="btn btn-secondary">
|
||||
Edit
|
||||
</Link>
|
||||
{template && (
|
||||
<Link to={`/templates/${template.id}/fill`} className="btn btn-secondary">
|
||||
Reuse Template
|
||||
</Link>
|
||||
)}
|
||||
{canManage && (
|
||||
<button className="btn btn-danger" onClick={handleDelete}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Preview</h2>
|
||||
{previewHtml ? (
|
||||
<div
|
||||
className="doc-preview"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p>Preview unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Field Data</h2>
|
||||
<pre style={{
|
||||
background: '#f8fafc',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
overflow: 'auto',
|
||||
fontSize: '0.85rem',
|
||||
}}>
|
||||
{JSON.stringify(document.field_data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
frontend/src/pages/DocumentsPage.tsx
Normal file
96
frontend/src/pages/DocumentsPage.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { FilledDocument } from '../types'
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const { user } = useAuth()
|
||||
const [documents, setDocuments] = useState<FilledDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = () => {
|
||||
api.listDocuments()
|
||||
.then(setDocuments)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load'))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (doc: FilledDocument) => {
|
||||
if (!confirm(`Delete document "${doc.name}"?`)) return
|
||||
try {
|
||||
await api.deleteDocument(doc.id)
|
||||
setDocuments((list) => list.filter((d) => d.id !== doc.id))
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<h1>Documents</h1>
|
||||
<Link to="/templates" className="btn btn-primary">
|
||||
Create from Template
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="card empty-state">
|
||||
<p>No filled documents yet. Choose a template and fill in the variables.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
{isAdmin && <th>Owner</th>}
|
||||
<th>Template ID</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td><strong>{d.name}</strong></td>
|
||||
{isAdmin && <td>{d.owner_username ?? `#${d.owner_id}`}</td>}
|
||||
<td>{d.template_id}</td>
|
||||
<td>{new Date(d.created_at).toLocaleString()}</td>
|
||||
<td style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/documents/${d.id}`} className="btn btn-primary btn-sm">
|
||||
View
|
||||
</Link>
|
||||
<Link to={`/documents/${d.id}/edit`} className="btn btn-secondary btn-sm">
|
||||
Edit
|
||||
</Link>
|
||||
{(isAdmin || d.owner_id === user?.id) && (
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDelete(d)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
frontend/src/pages/EditDocumentPage.tsx
Normal file
179
frontend/src/pages/EditDocumentPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import TableRowEditor from '../components/TableRowEditor'
|
||||
import VariableField from '../components/VariableField'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
export default function EditDocumentPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
|
||||
const [fieldData, setFieldData] = useState<Record<string, unknown>>({})
|
||||
const [documentName, setDocumentName] = useState('')
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||
const [step, setStep] = useState<2 | 3>(2)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const doc = await api.getDocument(Number(id))
|
||||
const t = await api.getTemplate(doc.template_id)
|
||||
setTemplate(t)
|
||||
setDocumentName(doc.name)
|
||||
setFieldData(doc.field_data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load document')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
const topLevelVars = useMemo(
|
||||
() => template?.variables.filter((v) => !v.parent_variable) ?? [],
|
||||
[template],
|
||||
)
|
||||
|
||||
const childVarsByParent = useMemo(() => {
|
||||
const map: Record<string, typeof topLevelVars> = {}
|
||||
template?.variables
|
||||
.filter((v) => v.parent_variable)
|
||||
.forEach((v) => {
|
||||
const parent = v.parent_variable!
|
||||
if (!map[parent]) map[parent] = []
|
||||
map[parent].push(v)
|
||||
})
|
||||
return map
|
||||
}, [template, topLevelVars])
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!template) return
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await api.previewDocument({
|
||||
template_id: template.id,
|
||||
name: documentName,
|
||||
field_data: fieldData,
|
||||
})
|
||||
setPreviewHtml(result.html)
|
||||
setStep(3)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Preview failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!template) return
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const doc = await api.updateDocument(Number(id), {
|
||||
name: documentName,
|
||||
field_data: fieldData,
|
||||
})
|
||||
navigate(`/documents/${doc.id}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="container"><p>Loading...</p></div>
|
||||
if (!template) return <div className="container"><div className="error">{error || 'Not found'}</div></div>
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Edit Document</h1>
|
||||
<div className="style-hint">Template: {template.name}</div>
|
||||
</div>
|
||||
<Link to={`/documents/${id}`} className="btn btn-secondary">Back</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="card" style={{ maxWidth: 720 }}>
|
||||
<div className="form-group">
|
||||
<label>Document Name *</label>
|
||||
<input
|
||||
value={documentName}
|
||||
onChange={(e) => setDocumentName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{topLevelVars.map((variable) => {
|
||||
if (variable.field_type === 'table_row') {
|
||||
const children = childVarsByParent[variable.name] ?? []
|
||||
return (
|
||||
<TableRowEditor
|
||||
key={variable.id}
|
||||
tableVariable={variable}
|
||||
childVariables={children}
|
||||
rows={(fieldData[variable.name] as Record<string, unknown>[]) ?? []}
|
||||
onChange={(rows) =>
|
||||
setFieldData((prev) => ({ ...prev, [variable.name]: rows }))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<VariableField
|
||||
key={variable.id}
|
||||
variable={variable}
|
||||
value={fieldData[variable.name]}
|
||||
onChange={(val) =>
|
||||
setFieldData((prev) => ({ ...prev, [variable.name]: val }))
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 24 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePreview}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Rendering...' : 'Preview'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && previewHtml && (
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
className="doc-preview"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn btn-secondary" onClick={() => setStep(2)}>
|
||||
Edit Fields
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
226
frontend/src/pages/FillTemplatePage.tsx
Normal file
226
frontend/src/pages/FillTemplatePage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import TableRowEditor from '../components/TableRowEditor'
|
||||
import VariableField from '../components/VariableField'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
export default function FillTemplatePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
|
||||
const [fieldData, setFieldData] = useState<Record<string, unknown>>({})
|
||||
const [documentName, setDocumentName] = useState('')
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
|
||||
const [step, setStep] = useState<1 | 2 | 3>(2)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const t = await api.getTemplate(Number(id))
|
||||
setTemplate(t)
|
||||
setDocumentName(`${t.name} - ${new Date().toLocaleDateString()}`)
|
||||
|
||||
const initial: Record<string, unknown> = {}
|
||||
t.variables.forEach((v) => {
|
||||
if (v.field_type === 'table_row') {
|
||||
initial[v.name] = []
|
||||
} else if (!v.parent_variable) {
|
||||
initial[v.name] = v.default_value ?? ''
|
||||
}
|
||||
})
|
||||
setFieldData(initial)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load template')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
const topLevelVars = useMemo(
|
||||
() => template?.variables.filter((v) => !v.parent_variable) ?? [],
|
||||
[template],
|
||||
)
|
||||
|
||||
const childVarsByParent = useMemo(() => {
|
||||
const map: Record<string, typeof topLevelVars> = {}
|
||||
template?.variables
|
||||
.filter((v) => v.parent_variable)
|
||||
.forEach((v) => {
|
||||
const parent = v.parent_variable!
|
||||
if (!map[parent]) map[parent] = []
|
||||
map[parent].push(v)
|
||||
})
|
||||
return map
|
||||
}, [template, topLevelVars])
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!template) return
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await api.previewDocument({
|
||||
template_id: template.id,
|
||||
name: documentName,
|
||||
field_data: fieldData,
|
||||
})
|
||||
setPreviewHtml(result.html)
|
||||
setStep(3)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Preview failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!template) return
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const doc = await api.createDocument({
|
||||
template_id: template.id,
|
||||
name: documentName,
|
||||
field_data: fieldData,
|
||||
})
|
||||
navigate(`/documents/${doc.id}`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="container"><p>Loading...</p></div>
|
||||
if (!template) return <div className="container"><div className="error">{error || 'Not found'}</div></div>
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Fill: {template.name}</h1>
|
||||
<div className="style-hint">Reusable template · {template.variables.length} variables detected</div>
|
||||
</div>
|
||||
<Link to="/templates" className="btn btn-secondary">Back</Link>
|
||||
</div>
|
||||
|
||||
<div className="steps">
|
||||
<div className="step done">1. Upload .docx</div>
|
||||
<div className={`step ${step === 2 ? 'active' : step > 2 ? 'done' : ''}`}>2. Fill Variables</div>
|
||||
<div className={`step ${step === 3 ? 'active' : ''}`}>3. Preview & Export</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Document Fields</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Document Name *</label>
|
||||
<input
|
||||
value={documentName}
|
||||
onChange={(e) => setDocumentName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{topLevelVars.map((variable) => {
|
||||
if (variable.field_type === 'table_row') {
|
||||
const children = childVarsByParent[variable.name] ?? []
|
||||
return (
|
||||
<TableRowEditor
|
||||
key={variable.id}
|
||||
tableVariable={variable}
|
||||
childVariables={children}
|
||||
rows={(fieldData[variable.name] as Record<string, unknown>[]) ?? []}
|
||||
onChange={(rows) =>
|
||||
setFieldData((prev) => ({ ...prev, [variable.name]: rows }))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<VariableField
|
||||
key={variable.id}
|
||||
variable={variable}
|
||||
value={fieldData[variable.name]}
|
||||
onChange={(val) =>
|
||||
setFieldData((prev) => ({ ...prev, [variable.name]: val }))
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 24 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePreview}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Rendering...' : 'Preview Document'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Template Info</h2>
|
||||
<p><strong>File:</strong> {template.original_filename}</p>
|
||||
{template.description && <p>{template.description}</p>}
|
||||
<h3>Detected Variables</h3>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Style</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{template.variables.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td>{v.name}</td>
|
||||
<td>{v.field_type}</td>
|
||||
<td className="style-hint">
|
||||
{v.style_params?.font_name ?? '—'}
|
||||
{v.style_params?.alignment ? ` / ${v.style_params.alignment}` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && previewHtml && (
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
className="doc-preview"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn btn-secondary" onClick={() => setStep(2)}>
|
||||
Edit Fields
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save & Export'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/pages/ForgotPasswordPage.tsx
Normal file
59
frontend/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await api.forgotPassword(email)
|
||||
setSuccess(result.message)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Request failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="card auth-card">
|
||||
<h1>Forgot Password</h1>
|
||||
<p>Enter your email and we will send a reset code.</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Send Reset Code'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
Have a code? <Link to="/reset-password">Reset password</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/login">Back to login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
frontend/src/pages/LoginPage.tsx
Normal file
72
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
if (user) return <Navigate to="/templates" replace />
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="card auth-card">
|
||||
<h1>Document Template Editor</h1>
|
||||
<p>Upload Jinja2 .docx templates, fill variables, preview and export.</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: 20, fontSize: '0.9rem' }}>
|
||||
<p style={{ margin: '8px 0' }}>
|
||||
<Link to="/register">Create account</Link>
|
||||
</p>
|
||||
<p style={{ margin: '8px 0' }}>
|
||||
<Link to="/activate">Activate account</Link>
|
||||
</p>
|
||||
<p style={{ margin: '8px 0' }}>
|
||||
<Link to="/forgot-password">Forgot password?</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
frontend/src/pages/RegisterPage.tsx
Normal file
99
frontend/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await register(email, username, password, confirmPassword)
|
||||
setSuccess(result.message)
|
||||
setTimeout(() => {
|
||||
navigate('/activate', { state: { email: result.email } })
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="card auth-card">
|
||||
<h1>Create Account</h1>
|
||||
<p>Register to use document templates. You will receive an activation code by email.</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
Already have an account? <Link to="/login">Login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
frontend/src/pages/ResetPasswordPage.tsx
Normal file
100
frontend/src/pages/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await api.resetPassword({
|
||||
email,
|
||||
code,
|
||||
password,
|
||||
confirm_password: confirmPassword,
|
||||
})
|
||||
setSuccess(result.message)
|
||||
setTimeout(() => navigate('/login'), 1500)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Reset failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="card auth-card">
|
||||
<h1>Reset Password</h1>
|
||||
<p>Enter the code from your email and choose a new password.</p>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Reset Code</label>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Please wait...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/login">Back to login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
frontend/src/pages/TemplateDetailPage.tsx
Normal file
109
frontend/src/pages/TemplateDetailPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api, downloadWithAuth } from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
|
||||
export default function TemplateDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { user } = useAuth()
|
||||
const [template, setTemplate] = useState<DocumentTemplate | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.getTemplate(Number(id))
|
||||
.then(setTemplate)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleTogglePublic = async () => {
|
||||
if (!template) return
|
||||
try {
|
||||
const updated = await api.updateTemplate(template.id, {
|
||||
is_public: !template.is_public,
|
||||
})
|
||||
setTemplate(updated)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadSource = async () => {
|
||||
if (!template) return
|
||||
try {
|
||||
await downloadWithAuth(
|
||||
api.getTemplateSourceUrl(template.id),
|
||||
template.original_filename,
|
||||
)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Download failed')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="container"><p>Loading...</p></div>
|
||||
if (!template) return <div className="container"><div className="error">{error}</div></div>
|
||||
|
||||
const canManage = template.is_owner || user?.role === 'admin'
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<h1>{template.name}</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/templates/${template.id}/fill`} className="btn btn-primary">
|
||||
Use Template
|
||||
</Link>
|
||||
<button className="btn btn-secondary" onClick={handleDownloadSource}>
|
||||
Download Source
|
||||
</button>
|
||||
{canManage && (
|
||||
<button className="btn btn-secondary" onClick={handleTogglePublic}>
|
||||
{template.is_public ? 'Make Private' : 'Make Public'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<p><strong>File:</strong> {template.original_filename}</p>
|
||||
<p><strong>Owner:</strong> {template.owner_username ?? `#${template.owner_id}`}</p>
|
||||
<p>
|
||||
<strong>Visibility:</strong>{' '}
|
||||
{template.is_public ? 'Public (visible to all users)' : 'Private'}
|
||||
</p>
|
||||
{template.description && <p>{template.description}</p>}
|
||||
<p><strong>Created:</strong> {new Date(template.created_at).toLocaleString()}</p>
|
||||
|
||||
<h2>Variables ({template.variables.length})</h2>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Style Parameters</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{template.variables.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td>{v.label}</td>
|
||||
<td><code>{v.name}</code></td>
|
||||
<td>{v.field_type}</td>
|
||||
<td>{v.is_required ? 'Yes' : 'No'}</td>
|
||||
<td className="style-hint">
|
||||
{v.style_params
|
||||
? JSON.stringify(v.style_params, null, 0).slice(0, 80) + '...'
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
frontend/src/pages/TemplatesPage.tsx
Normal file
160
frontend/src/pages/TemplatesPage.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api, downloadWithAuth } from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { TemplateListItem } from '../types'
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const { user } = useAuth()
|
||||
const [templates, setTemplates] = useState<TemplateListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setTemplates(await api.listTemplates())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load templates')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (t: TemplateListItem) => {
|
||||
if (!confirm(`Delete template "${t.name}"?`)) return
|
||||
try {
|
||||
await api.deleteTemplate(t.id)
|
||||
setTemplates((list) => list.filter((x) => x.id !== t.id))
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePublic = async (t: TemplateListItem) => {
|
||||
try {
|
||||
const updated = await api.updateTemplate(t.id, { is_public: !t.is_public })
|
||||
setTemplates((list) =>
|
||||
list.map((x) =>
|
||||
x.id === t.id
|
||||
? { ...x, is_public: updated.is_public }
|
||||
: x,
|
||||
),
|
||||
)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadSource = async (t: TemplateListItem) => {
|
||||
try {
|
||||
await downloadWithAuth(
|
||||
api.getTemplateSourceUrl(t.id),
|
||||
t.original_filename,
|
||||
)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Download failed')
|
||||
}
|
||||
}
|
||||
|
||||
const canManage = (t: TemplateListItem) =>
|
||||
t.is_owner || user?.role === 'admin'
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<h1>Templates</h1>
|
||||
<Link to="/templates/new" className="btn btn-primary">
|
||||
+ Upload Template
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="card empty-state">
|
||||
<p>No templates yet. Upload a .docx file with Jinja2 variables to get started.</p>
|
||||
<Link to="/templates/new" className="btn btn-primary" style={{ marginTop: 16 }}>
|
||||
Upload Template
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>File</th>
|
||||
<th>Owner</th>
|
||||
<th>Visibility</th>
|
||||
<th>Variables</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td>
|
||||
<strong>{t.name}</strong>
|
||||
{t.description && (
|
||||
<div className="style-hint">{t.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{t.original_filename}</td>
|
||||
<td>{t.owner_username ?? `#${t.owner_id}`}</td>
|
||||
<td>
|
||||
{t.is_public ? (
|
||||
<span className="badge" style={{ background: '#dbeafe', color: '#1d4ed8' }}>
|
||||
public
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-user">private</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{t.variable_count}</td>
|
||||
<td>{new Date(t.created_at).toLocaleDateString()}</td>
|
||||
<td style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Link to={`/templates/${t.id}/fill`} className="btn btn-primary btn-sm">
|
||||
Fill
|
||||
</Link>
|
||||
<Link to={`/templates/${t.id}`} className="btn btn-secondary btn-sm">
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => handleDownloadSource(t)}
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
{canManage(t) && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => handleTogglePublic(t)}
|
||||
>
|
||||
{t.is_public ? 'Make Private' : 'Make Public'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDelete(t)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
307
frontend/src/pages/UsersPage.tsx
Normal file
307
frontend/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { User } from '../types'
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'user' as 'user' | 'admin',
|
||||
})
|
||||
const [editForm, setEditForm] = useState({
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'user' as 'user' | 'admin',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setUsers(await api.listUsers())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load users')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await api.createUser(form)
|
||||
setForm({ email: '', username: '', password: '', role: 'user' })
|
||||
setShowForm(false)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create user')
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (u: User) => {
|
||||
setEditingUser(u)
|
||||
setEditForm({
|
||||
email: u.email,
|
||||
username: u.username,
|
||||
password: '',
|
||||
role: u.role,
|
||||
is_active: u.is_active,
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editingUser) return
|
||||
setError('')
|
||||
try {
|
||||
await api.updateUser(editingUser.id, {
|
||||
email: editForm.email,
|
||||
username: editForm.username,
|
||||
role: editForm.role,
|
||||
is_active: editForm.is_active,
|
||||
...(editForm.password ? { password: editForm.password } : {}),
|
||||
})
|
||||
setEditingUser(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (u: User) => {
|
||||
try {
|
||||
await api.updateUser(u.id, { is_active: !u.is_active })
|
||||
await load()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Update failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (u: User) => {
|
||||
if (!confirm(`Delete user "${u.username}"?`)) return
|
||||
try {
|
||||
await api.deleteUser(u.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUser?.role !== 'admin') {
|
||||
return <Navigate to="/templates" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<h1>User Management</h1>
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||
{showForm ? 'Cancel' : '+ Add User'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{showForm && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Create User</h2>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Role</label>
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as 'user' | 'admin' })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingUser && (
|
||||
<div className="card" style={{ marginBottom: 24, maxWidth: 480 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Edit User: {editingUser.username}</h2>
|
||||
<form onSubmit={handleEdit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
value={editForm.username}
|
||||
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>New Password (leave empty to keep)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password}
|
||||
onChange={(e) => setEditForm({ ...editForm, password: e.target.value })}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Role</label>
|
||||
<select
|
||||
value={editForm.role}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, role: e.target.value as 'user' | 'admin' })
|
||||
}
|
||||
disabled={editingUser.id === currentUser?.id}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.is_active}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, is_active: e.target.checked })
|
||||
}
|
||||
disabled={editingUser.id === currentUser?.id}
|
||||
style={{ width: 'auto', marginRight: 8 }}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn btn-primary">Save</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<strong>{u.username}</strong>
|
||||
{u.id === currentUser?.id && (
|
||||
<span className="style-hint"> (you)</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{u.email}</td>
|
||||
<td>{u.role}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge badge-${u.is_active ? 'user' : 'admin'}`}
|
||||
style={
|
||||
u.is_active
|
||||
? { background: '#dcfce7', color: '#16a34a' }
|
||||
: { background: '#fee2e2', color: '#dc2626' }
|
||||
}
|
||||
>
|
||||
{u.is_active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => openEdit(u)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => handleToggleActive(u)}
|
||||
disabled={u.id === currentUser?.id}
|
||||
>
|
||||
{u.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDelete(u)}
|
||||
disabled={u.id === currentUser?.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
frontend/src/types.ts
Normal file
80
frontend/src/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export type UserRole = 'user' | 'admin'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
username: string
|
||||
role: UserRole
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface StyleParams {
|
||||
font_name?: string
|
||||
font_size?: number
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
color?: string
|
||||
alignment?: string
|
||||
background_color?: string
|
||||
border_style?: string
|
||||
width?: number
|
||||
height?: number
|
||||
table_style?: Record<string, unknown>
|
||||
is_repeating_table?: boolean
|
||||
}
|
||||
|
||||
export interface TemplateVariable {
|
||||
id: number
|
||||
name: string
|
||||
label: string
|
||||
field_type: string
|
||||
default_value?: string | null
|
||||
is_required: boolean
|
||||
order: number
|
||||
parent_variable?: string | null
|
||||
style_params?: StyleParams | null
|
||||
}
|
||||
|
||||
export interface DocumentTemplate {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
original_filename: string
|
||||
owner_id: number
|
||||
owner_username?: string | null
|
||||
is_public: boolean
|
||||
is_owner: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
variables: TemplateVariable[]
|
||||
}
|
||||
|
||||
export interface TemplateListItem {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
original_filename: string
|
||||
owner_id: number
|
||||
owner_username?: string | null
|
||||
is_public: boolean
|
||||
is_owner: boolean
|
||||
created_at: string
|
||||
variable_count: number
|
||||
}
|
||||
|
||||
export interface FilledDocument {
|
||||
id: number
|
||||
template_id: number
|
||||
owner_id: number
|
||||
owner_username?: string | null
|
||||
name: string
|
||||
field_data: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PreviewResponse {
|
||||
html: string
|
||||
field_data: Record<string, unknown>
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/Navbar.tsx","./src/components/TableRowEditor.tsx","./src/components/VariableField.tsx","./src/context/AuthContext.tsx","./src/pages/ActivatePage.tsx","./src/pages/CreateTemplatePage.tsx","./src/pages/DocumentDetailPage.tsx","./src/pages/DocumentsPage.tsx","./src/pages/EditDocumentPage.tsx","./src/pages/FillTemplatePage.tsx","./src/pages/ForgotPasswordPage.tsx","./src/pages/LoginPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/ResetPasswordPage.tsx","./src/pages/TemplateDetailPage.tsx","./src/pages/TemplatesPage.tsx","./src/pages/UsersPage.tsx"],"version":"5.9.3"}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
53
samples/create_sample_template.py
Normal file
53
samples/create_sample_template.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Create a sample .docx template with Jinja2 variables for testing."""
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def set_cell_text(cell, text: str) -> None:
|
||||
cell.text = ""
|
||||
cell.paragraphs[0].add_run(text)
|
||||
|
||||
|
||||
output = Path(__file__).parent / "invoice_template.docx"
|
||||
|
||||
doc = Document()
|
||||
|
||||
title = doc.add_paragraph()
|
||||
run = title.add_run("Invoice Document")
|
||||
run.bold = True
|
||||
run.font.size = Pt(18)
|
||||
|
||||
doc.add_paragraph("Client: {{ client_name }}")
|
||||
doc.add_paragraph("Date: {{ invoice_date }}")
|
||||
doc.add_paragraph("Invoice #: {{ invoice_number }}")
|
||||
|
||||
doc.add_paragraph()
|
||||
doc.add_paragraph("Description:")
|
||||
doc.add_paragraph("{{ description }}")
|
||||
|
||||
doc.add_paragraph()
|
||||
doc.add_paragraph("Items:")
|
||||
|
||||
table = doc.add_table(rows=2, cols=3)
|
||||
table.style = "Table Grid"
|
||||
|
||||
hdr = table.rows[0].cells
|
||||
set_cell_text(hdr[0], "Product")
|
||||
set_cell_text(hdr[1], "Quantity")
|
||||
set_cell_text(hdr[2], "Price")
|
||||
|
||||
data_row = table.rows[1].cells
|
||||
set_cell_text(data_row[0], "{%tr for item in items %}{{ item.product }}")
|
||||
set_cell_text(data_row[1], "{{ item.qty }}")
|
||||
set_cell_text(data_row[2], "{{ item.price }}{%tr endfor %}")
|
||||
|
||||
doc.add_paragraph()
|
||||
doc.add_paragraph("Total: {{ total_amount }}")
|
||||
|
||||
doc.add_paragraph()
|
||||
doc.add_paragraph("Prepared by: {{ author_name }}")
|
||||
|
||||
doc.save(str(output))
|
||||
print(f"Created: {output}")
|
||||
BIN
samples/invoice_template.docx
Normal file
BIN
samples/invoice_template.docx
Normal file
Binary file not shown.
BIN
samples/test_diplomform.docx
Normal file
BIN
samples/test_diplomform.docx
Normal file
Binary file not shown.
Reference in New Issue
Block a user