load project

This commit is contained in:
2026-06-15 09:18:58 +03:00
commit bf1839607f
70 changed files with 7503 additions and 0 deletions

29
.env.example Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,11 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
app.db
uploads
exports
.env
.git
.gitignore

23
backend/.env.example Normal file
View 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
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
app.db
uploads/
exports/
.env

20
backend/Dockerfile Normal file
View 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
View File

87
backend/app/auth.py Normal file
View 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
View 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
View 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
View 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
View 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)"
)
)

View 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",
]

View 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")

View 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")

View 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",
)

View 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")

View File

160
backend/app/routers/auth.py Normal file
View 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

View 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")

View 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()

View 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
View 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
View 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()

View File

View 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

View 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
]

View 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}"
)

View 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)

View 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)

View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
node_modules
dist
.git
.gitignore
*.local

4
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.local

18
frontend/Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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
View 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
View 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)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend/tsconfig.json Normal file
View 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"]
}

View 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
View 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,
},
},
},
})

View 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}")

Binary file not shown.

Binary file not shown.