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

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