Update all files to use LR (windows)

This commit is contained in:
Martin Tan 2023-08-02 13:14:16 +08:00
parent 17d69224d9
commit e9ae9ad2eb
13 changed files with 4489 additions and 4489 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,64 +1,64 @@
import dataclasses
import datetime
import enum
from pathlib import Path
from typing import List
from typing import Optional
import magic
@dataclasses.dataclass
class DocumentMetadataOverrides:
"""
Manages overrides for document fields which normally would
be set from content or matching. All fields default to None,
meaning no override is happening
"""
filename: Optional[str] = None
title: Optional[str] = None
correspondent_id: Optional[int] = None
document_type_id: Optional[int] = None
tag_ids: Optional[List[int]] = None
created: Optional[datetime.datetime] = None
asn: Optional[int] = None
owner_id: Optional[int] = None
storage_path_id: Optional[int] = None
full_path: Optional[str] = None
class DocumentSource(enum.IntEnum):
"""
The source of an incoming document. May have other uses in the future
"""
ConsumeFolder = enum.auto()
ApiUpload = enum.auto()
MailFetch = enum.auto()
@dataclasses.dataclass
class ConsumableDocument:
"""
Encapsulates an incoming document, either from consume folder, API upload
or mail fetching and certain useful operations on it.
"""
source: DocumentSource
original_file: Path
mime_type: str = dataclasses.field(init=False, default=None)
def __post_init__(self):
"""
After a dataclass is initialized, this is called to finalize some data
1. Make sure the original path is an absolute, fully qualified path
2. Get the mime type of the file
"""
# Always fully qualify the path first thing
# Just in case, convert to a path if it's a str
self.original_file = Path(self.original_file).resolve()
# Get the file type once at init
# Note this function isn't called when the object is unpickled
self.mime_type = magic.from_file(self.original_file, mime=True)
import dataclasses
import datetime
import enum
from pathlib import Path
from typing import List
from typing import Optional
import magic
@dataclasses.dataclass
class DocumentMetadataOverrides:
"""
Manages overrides for document fields which normally would
be set from content or matching. All fields default to None,
meaning no override is happening
"""
filename: Optional[str] = None
title: Optional[str] = None
correspondent_id: Optional[int] = None
document_type_id: Optional[int] = None
tag_ids: Optional[List[int]] = None
created: Optional[datetime.datetime] = None
asn: Optional[int] = None
owner_id: Optional[int] = None
storage_path_id: Optional[int] = None
full_path: Optional[str] = None
class DocumentSource(enum.IntEnum):
"""
The source of an incoming document. May have other uses in the future
"""
ConsumeFolder = enum.auto()
ApiUpload = enum.auto()
MailFetch = enum.auto()
@dataclasses.dataclass
class ConsumableDocument:
"""
Encapsulates an incoming document, either from consume folder, API upload
or mail fetching and certain useful operations on it.
"""
source: DocumentSource
original_file: Path
mime_type: str = dataclasses.field(init=False, default=None)
def __post_init__(self):
"""
After a dataclass is initialized, this is called to finalize some data
1. Make sure the original path is an absolute, fully qualified path
2. Get the mime type of the file
"""
# Always fully qualify the path first thing
# Just in case, convert to a path if it's a str
self.original_file = Path(self.original_file).resolve()
# Get the file type once at init
# Note this function isn't called when the object is unpickled
self.mime_type = magic.from_file(self.original_file, mime=True)

View File

@ -1,159 +1,159 @@
from django.db.models import Q
from django_filters.rest_framework import BooleanFilter
from django_filters.rest_framework import Filter
from django_filters.rest_framework import FilterSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from .models import Correspondent
from .models import Document
from .models import DocumentType
from .models import Log
from .models import StoragePath
from .models import Tag
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
ID_KWARGS = ["in", "exact"]
INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"]
DATE_KWARGS = ["year", "month", "day", "date__gt", "gt", "date__lt", "lt"]
class CorrespondentFilterSet(FilterSet):
class Meta:
model = Correspondent
fields = {"name": CHAR_KWARGS}
class TagFilterSet(FilterSet):
class Meta:
model = Tag
fields = {"name": CHAR_KWARGS}
class DocumentTypeFilterSet(FilterSet):
class Meta:
model = DocumentType
fields = {"name": CHAR_KWARGS}
class ObjectFilter(Filter):
def __init__(self, exclude=False, in_list=False, field_name=""):
super().__init__()
self.exclude = exclude
self.in_list = in_list
self.field_name = field_name
def filter(self, qs, value):
if not value:
return qs
try:
object_ids = [int(x) for x in value.split(",")]
except ValueError:
return qs
if self.in_list:
qs = qs.filter(**{f"{self.field_name}__id__in": object_ids}).distinct()
else:
for obj_id in object_ids:
if self.exclude:
qs = qs.exclude(**{f"{self.field_name}__id": obj_id})
else:
qs = qs.filter(**{f"{self.field_name}__id": obj_id})
return qs
class InboxFilter(Filter):
def filter(self, qs, value):
if value == "true":
return qs.filter(tags__is_inbox_tag=True)
elif value == "false":
return qs.exclude(tags__is_inbox_tag=True)
else:
return qs
class TitleContentFilter(Filter):
def filter(self, qs, value):
if value:
return qs.filter(Q(title__icontains=value) | Q(content__icontains=value))
else:
return qs
class DocumentFilterSet(FilterSet):
is_tagged = BooleanFilter(
label="Is tagged",
field_name="tags",
lookup_expr="isnull",
exclude=True,
)
tags__id__all = ObjectFilter(field_name="tags")
tags__id__none = ObjectFilter(field_name="tags", exclude=True)
tags__id__in = ObjectFilter(field_name="tags", in_list=True)
correspondent__id__none = ObjectFilter(field_name="correspondent", exclude=True)
document_type__id__none = ObjectFilter(field_name="document_type", exclude=True)
storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True)
is_in_inbox = InboxFilter()
title_content = TitleContentFilter()
class Meta:
model = Document
fields = {
"title": CHAR_KWARGS,
"content": CHAR_KWARGS,
"archive_serial_number": INT_KWARGS,
"created": DATE_KWARGS,
"added": DATE_KWARGS,
"modified": DATE_KWARGS,
"correspondent": ["isnull"],
"correspondent__id": ID_KWARGS,
"correspondent__name": CHAR_KWARGS,
"tags__id": ID_KWARGS,
"tags__name": CHAR_KWARGS,
"document_type": ["isnull"],
"document_type__id": ID_KWARGS,
"document_type__name": CHAR_KWARGS,
"storage_path": ["isnull"],
"storage_path__id": ID_KWARGS,
"storage_path__name": CHAR_KWARGS,
}
class LogFilterSet(FilterSet):
class Meta:
model = Log
fields = {"level": INT_KWARGS, "created": DATE_KWARGS, "group": ID_KWARGS}
class StoragePathFilterSet(FilterSet):
class Meta:
model = StoragePath
fields = {
"name": CHAR_KWARGS,
"path": CHAR_KWARGS,
}
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
"""
A filter backend that limits results to those where the requesting user
has read object level permissions, owns the objects, or objects without
an owner (for backwards compat)
"""
def filter_queryset(self, request, queryset, view):
objects_with_perms = super().filter_queryset(request, queryset, view)
objects_owned = queryset.filter(owner=request.user)
objects_unowned = queryset.filter(owner__isnull=True)
return objects_with_perms | objects_owned | objects_unowned
from django.db.models import Q
from django_filters.rest_framework import BooleanFilter
from django_filters.rest_framework import Filter
from django_filters.rest_framework import FilterSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from .models import Correspondent
from .models import Document
from .models import DocumentType
from .models import Log
from .models import StoragePath
from .models import Tag
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
ID_KWARGS = ["in", "exact"]
INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"]
DATE_KWARGS = ["year", "month", "day", "date__gt", "gt", "date__lt", "lt"]
class CorrespondentFilterSet(FilterSet):
class Meta:
model = Correspondent
fields = {"name": CHAR_KWARGS}
class TagFilterSet(FilterSet):
class Meta:
model = Tag
fields = {"name": CHAR_KWARGS}
class DocumentTypeFilterSet(FilterSet):
class Meta:
model = DocumentType
fields = {"name": CHAR_KWARGS}
class ObjectFilter(Filter):
def __init__(self, exclude=False, in_list=False, field_name=""):
super().__init__()
self.exclude = exclude
self.in_list = in_list
self.field_name = field_name
def filter(self, qs, value):
if not value:
return qs
try:
object_ids = [int(x) for x in value.split(",")]
except ValueError:
return qs
if self.in_list:
qs = qs.filter(**{f"{self.field_name}__id__in": object_ids}).distinct()
else:
for obj_id in object_ids:
if self.exclude:
qs = qs.exclude(**{f"{self.field_name}__id": obj_id})
else:
qs = qs.filter(**{f"{self.field_name}__id": obj_id})
return qs
class InboxFilter(Filter):
def filter(self, qs, value):
if value == "true":
return qs.filter(tags__is_inbox_tag=True)
elif value == "false":
return qs.exclude(tags__is_inbox_tag=True)
else:
return qs
class TitleContentFilter(Filter):
def filter(self, qs, value):
if value:
return qs.filter(Q(title__icontains=value) | Q(content__icontains=value))
else:
return qs
class DocumentFilterSet(FilterSet):
is_tagged = BooleanFilter(
label="Is tagged",
field_name="tags",
lookup_expr="isnull",
exclude=True,
)
tags__id__all = ObjectFilter(field_name="tags")
tags__id__none = ObjectFilter(field_name="tags", exclude=True)
tags__id__in = ObjectFilter(field_name="tags", in_list=True)
correspondent__id__none = ObjectFilter(field_name="correspondent", exclude=True)
document_type__id__none = ObjectFilter(field_name="document_type", exclude=True)
storage_path__id__none = ObjectFilter(field_name="storage_path", exclude=True)
is_in_inbox = InboxFilter()
title_content = TitleContentFilter()
class Meta:
model = Document
fields = {
"title": CHAR_KWARGS,
"content": CHAR_KWARGS,
"archive_serial_number": INT_KWARGS,
"created": DATE_KWARGS,
"added": DATE_KWARGS,
"modified": DATE_KWARGS,
"correspondent": ["isnull"],
"correspondent__id": ID_KWARGS,
"correspondent__name": CHAR_KWARGS,
"tags__id": ID_KWARGS,
"tags__name": CHAR_KWARGS,
"document_type": ["isnull"],
"document_type__id": ID_KWARGS,
"document_type__name": CHAR_KWARGS,
"storage_path": ["isnull"],
"storage_path__id": ID_KWARGS,
"storage_path__name": CHAR_KWARGS,
}
class LogFilterSet(FilterSet):
class Meta:
model = Log
fields = {"level": INT_KWARGS, "created": DATE_KWARGS, "group": ID_KWARGS}
class StoragePathFilterSet(FilterSet):
class Meta:
model = StoragePath
fields = {
"name": CHAR_KWARGS,
"path": CHAR_KWARGS,
}
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
"""
A filter backend that limits results to those where the requesting user
has read object level permissions, owns the objects, or objects without
an owner (for backwards compat)
"""
def filter_queryset(self, request, queryset, view):
objects_with_perms = super().filter_queryset(request, queryset, view)
objects_owned = queryset.filter(owner=request.user)
objects_unowned = queryset.filter(owner__isnull=True)
return objects_with_perms | objects_owned | objects_unowned

View File

@ -1,343 +1,343 @@
import logging
import math
import os
from contextlib import contextmanager
from dateutil.parser import isoparse
from django.conf import settings
from django.utils import timezone
from documents.models import Document, Metadata
from documents.models import Note
from guardian.shortcuts import get_users_with_perms
from whoosh import classify
from whoosh import highlight
from whoosh import query
from whoosh.fields import BOOLEAN
from whoosh.fields import DATETIME
from whoosh.fields import KEYWORD
from whoosh.fields import NUMERIC
from whoosh.fields import Schema
from whoosh.fields import TEXT
from whoosh.highlight import HtmlFormatter
from whoosh.index import create_in
from whoosh.index import exists_in
from whoosh.index import open_dir
from whoosh.qparser import MultifieldParser
from whoosh.qparser.dateparse import DateParserPlugin
from whoosh.searching import ResultsPage
from whoosh.searching import Searcher
from whoosh.writing import AsyncWriter
logger = logging.getLogger("paperless.index")
def get_schema():
return Schema(
id=NUMERIC(stored=True, unique=True),
title=TEXT(sortable=True),
content=TEXT(),
asn=NUMERIC(sortable=True, signed=False),
correspondent=TEXT(sortable=True),
correspondent_id=NUMERIC(),
has_correspondent=BOOLEAN(),
tag=KEYWORD(commas=True, scorable=True, lowercase=True),
tag_id=KEYWORD(commas=True, scorable=True),
has_tag=BOOLEAN(),
type=TEXT(sortable=True),
type_id=NUMERIC(),
has_type=BOOLEAN(),
created=DATETIME(sortable=True),
modified=DATETIME(sortable=True),
added=DATETIME(sortable=True),
path=TEXT(sortable=True),
path_id=NUMERIC(),
has_path=BOOLEAN(),
notes=TEXT(),
metadatas=TEXT(),
owner=TEXT(),
owner_id=NUMERIC(),
has_owner=BOOLEAN(),
viewer_id=KEYWORD(commas=True),
)
def open_index(recreate=False):
try:
if exists_in(settings.INDEX_DIR) and not recreate:
return open_dir(settings.INDEX_DIR, schema=get_schema())
except Exception:
logger.exception("Error while opening the index, recreating.")
if not os.path.isdir(settings.INDEX_DIR):
os.makedirs(settings.INDEX_DIR, exist_ok=True)
return create_in(settings.INDEX_DIR, get_schema())
@contextmanager
def open_index_writer(optimize=False):
writer = AsyncWriter(open_index())
try:
yield writer
except Exception as e:
logger.exception(str(e))
writer.cancel()
finally:
writer.commit(optimize=optimize)
@contextmanager
def open_index_searcher():
searcher = open_index().searcher()
try:
yield searcher
finally:
searcher.close()
def update_document(writer: AsyncWriter, doc: Document):
tags = ",".join([t.name for t in doc.tags.all()])
tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
notes = ",".join([str(c.note) for c in Note.objects.filter(document=doc)])
latest_metadata = Metadata.objects.filter(document=doc).order_by('-created').first()
metadatas = str(latest_metadata) if latest_metadata else ''
asn = doc.archive_serial_number
if asn is not None and (
asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
or asn > Document.ARCHIVE_SERIAL_NUMBER_MAX
):
logger.error(
f"Not indexing Archive Serial Number {asn} of document {doc.pk}. "
f"ASN is out of range "
f"[{Document.ARCHIVE_SERIAL_NUMBER_MIN:,}, "
f"{Document.ARCHIVE_SERIAL_NUMBER_MAX:,}.",
)
asn = 0
users_with_perms = get_users_with_perms(
doc,
only_with_perms_in=["view_document"],
)
viewer_ids = ",".join([str(u.id) for u in users_with_perms])
writer.update_document(
id=doc.pk,
title=doc.title,
content=doc.content,
correspondent=doc.correspondent.name if doc.correspondent else None,
correspondent_id=doc.correspondent.id if doc.correspondent else None,
has_correspondent=doc.correspondent is not None,
tag=tags if tags else None,
tag_id=tags_ids if tags_ids else None,
has_tag=len(tags) > 0,
type=doc.document_type.name if doc.document_type else None,
type_id=doc.document_type.id if doc.document_type else None,
has_type=doc.document_type is not None,
created=doc.created,
added=doc.added,
asn=asn,
modified=doc.modified,
path=doc.storage_path.name if doc.storage_path else None,
path_id=doc.storage_path.id if doc.storage_path else None,
has_path=doc.storage_path is not None,
notes=notes,
# metadatas=metadatas,
owner=doc.owner.username if doc.owner else None,
owner_id=doc.owner.id if doc.owner else None,
has_owner=doc.owner is not None,
viewer_id=viewer_ids if viewer_ids else None,
)
def remove_document(writer, doc):
remove_document_by_id(writer, doc.pk)
def remove_document_by_id(writer, doc_id):
writer.delete_by_term("id", doc_id)
def add_or_update_document(document):
with open_index_writer() as writer:
update_document(writer, document)
def remove_document_from_index(document):
with open_index_writer() as writer:
remove_document(writer, document)
class DelayedQuery:
def _get_query(self):
raise NotImplementedError
def _get_query_filter(self):
criterias = []
for k, v in self.query_params.items():
if k == "correspondent__id":
criterias.append(query.Term("correspondent_id", v))
elif k == "tags__id__all":
for tag_id in v.split(","):
criterias.append(query.Term("tag_id", tag_id))
elif k == "tags__id__none":
for tag_id in v.split(","):
criterias.append(query.Not(query.Term("tag_id", tag_id)))
elif k == "document_type__id":
criterias.append(query.Term("type_id", v))
elif k == "correspondent__isnull":
criterias.append(query.Term("has_correspondent", v == "false"))
elif k == "is_tagged":
criterias.append(query.Term("has_tag", v == "true"))
elif k == "document_type__isnull":
criterias.append(query.Term("has_type", v == "false"))
elif k == "created__date__lt":
criterias.append(
query.DateRange("created", start=None, end=isoparse(v)),
)
elif k == "created__date__gt":
criterias.append(
query.DateRange("created", start=isoparse(v), end=None),
)
elif k == "added__date__gt":
criterias.append(query.DateRange("added", start=isoparse(v), end=None))
elif k == "added__date__lt":
criterias.append(query.DateRange("added", start=None, end=isoparse(v)))
elif k == "storage_path__id":
criterias.append(query.Term("path_id", v))
elif k == "storage_path__isnull":
criterias.append(query.Term("has_path", v == "false"))
user_criterias = [query.Term("has_owner", False)]
if "user" in self.query_params:
user_criterias.append(query.Term("owner_id", self.query_params["user"]))
user_criterias.append(
query.Term("viewer_id", str(self.query_params["user"])),
)
if len(criterias) > 0:
criterias.append(query.Or(user_criterias))
return query.And(criterias)
else:
return query.Or(user_criterias)
def _get_query_sortedby(self):
if "ordering" not in self.query_params:
return None, False
field: str = self.query_params["ordering"]
sort_fields_map = {
"created": "created",
"modified": "modified",
"added": "added",
"title": "title",
"correspondent__name": "correspondent",
"document_type__name": "type",
"archive_serial_number": "asn",
}
if field.startswith("-"):
field = field[1:]
reverse = True
else:
reverse = False
if field not in sort_fields_map:
return None, False
else:
return sort_fields_map[field], reverse
def __init__(self, searcher: Searcher, query_params, page_size):
self.searcher = searcher
self.query_params = query_params
self.page_size = page_size
self.saved_results = dict()
self.first_score = None
def __len__(self):
page = self[0:1]
return len(page)
def __getitem__(self, item):
if item.start in self.saved_results:
return self.saved_results[item.start]
q, mask = self._get_query()
sortedby, reverse = self._get_query_sortedby()
page: ResultsPage = self.searcher.search_page(
q,
mask=mask,
filter=self._get_query_filter(),
pagenum=math.floor(item.start / self.page_size) + 1,
pagelen=self.page_size,
sortedby=sortedby,
reverse=reverse,
)
page.results.fragmenter = highlight.ContextFragmenter(surround=50)
page.results.formatter = HtmlFormatter(tagname="span", between=" ... ")
if not self.first_score and len(page.results) > 0 and sortedby is None:
self.first_score = page.results[0].score
page.results.top_n = list(
map(
lambda hit: (
(hit[0] / self.first_score) if self.first_score else None,
hit[1],
),
page.results.top_n,
),
)
self.saved_results[item.start] = page
return page
class DelayedFullTextQuery(DelayedQuery):
def _get_query(self):
q_str = self.query_params["query"]
qp = MultifieldParser(
["content", "title", "correspondent", "tag", "type", "notes", "metadatas"],
self.searcher.ixreader.schema,
)
qp.add_plugin(DateParserPlugin(basedate=timezone.now()))
q = qp.parse(q_str)
corrected = self.searcher.correct_query(q, q_str)
if corrected.query != q:
corrected.query = corrected.string
return q, None
class DelayedMoreLikeThisQuery(DelayedQuery):
def _get_query(self):
more_like_doc_id = int(self.query_params["more_like_id"])
content = Document.objects.get(id=more_like_doc_id).content
docnum = self.searcher.document_number(id=more_like_doc_id)
kts = self.searcher.key_terms_from_text(
"content",
content,
numterms=20,
model=classify.Bo1Model,
normalize=False,
)
q = query.Or(
[query.Term("content", word, boost=weight) for word, weight in kts],
)
mask = {docnum}
return q, mask
def autocomplete(ix, term, limit=10):
with ix.reader() as reader:
terms = []
for (score, t) in reader.most_distinctive_terms(
"content",
number=limit,
prefix=term.lower(),
):
terms.append(t)
return terms
import logging
import math
import os
from contextlib import contextmanager
from dateutil.parser import isoparse
from django.conf import settings
from django.utils import timezone
from documents.models import Document, Metadata
from documents.models import Note
from guardian.shortcuts import get_users_with_perms
from whoosh import classify
from whoosh import highlight
from whoosh import query
from whoosh.fields import BOOLEAN
from whoosh.fields import DATETIME
from whoosh.fields import KEYWORD
from whoosh.fields import NUMERIC
from whoosh.fields import Schema
from whoosh.fields import TEXT
from whoosh.highlight import HtmlFormatter
from whoosh.index import create_in
from whoosh.index import exists_in
from whoosh.index import open_dir
from whoosh.qparser import MultifieldParser
from whoosh.qparser.dateparse import DateParserPlugin
from whoosh.searching import ResultsPage
from whoosh.searching import Searcher
from whoosh.writing import AsyncWriter
logger = logging.getLogger("paperless.index")
def get_schema():
return Schema(
id=NUMERIC(stored=True, unique=True),
title=TEXT(sortable=True),
content=TEXT(),
asn=NUMERIC(sortable=True, signed=False),
correspondent=TEXT(sortable=True),
correspondent_id=NUMERIC(),
has_correspondent=BOOLEAN(),
tag=KEYWORD(commas=True, scorable=True, lowercase=True),
tag_id=KEYWORD(commas=True, scorable=True),
has_tag=BOOLEAN(),
type=TEXT(sortable=True),
type_id=NUMERIC(),
has_type=BOOLEAN(),
created=DATETIME(sortable=True),
modified=DATETIME(sortable=True),
added=DATETIME(sortable=True),
path=TEXT(sortable=True),
path_id=NUMERIC(),
has_path=BOOLEAN(),
notes=TEXT(),
metadatas=TEXT(),
owner=TEXT(),
owner_id=NUMERIC(),
has_owner=BOOLEAN(),
viewer_id=KEYWORD(commas=True),
)
def open_index(recreate=False):
try:
if exists_in(settings.INDEX_DIR) and not recreate:
return open_dir(settings.INDEX_DIR, schema=get_schema())
except Exception:
logger.exception("Error while opening the index, recreating.")
if not os.path.isdir(settings.INDEX_DIR):
os.makedirs(settings.INDEX_DIR, exist_ok=True)
return create_in(settings.INDEX_DIR, get_schema())
@contextmanager
def open_index_writer(optimize=False):
writer = AsyncWriter(open_index())
try:
yield writer
except Exception as e:
logger.exception(str(e))
writer.cancel()
finally:
writer.commit(optimize=optimize)
@contextmanager
def open_index_searcher():
searcher = open_index().searcher()
try:
yield searcher
finally:
searcher.close()
def update_document(writer: AsyncWriter, doc: Document):
tags = ",".join([t.name for t in doc.tags.all()])
tags_ids = ",".join([str(t.id) for t in doc.tags.all()])
notes = ",".join([str(c.note) for c in Note.objects.filter(document=doc)])
latest_metadata = Metadata.objects.filter(document=doc).order_by('-created').first()
metadatas = str(latest_metadata) if latest_metadata else ''
asn = doc.archive_serial_number
if asn is not None and (
asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
or asn > Document.ARCHIVE_SERIAL_NUMBER_MAX
):
logger.error(
f"Not indexing Archive Serial Number {asn} of document {doc.pk}. "
f"ASN is out of range "
f"[{Document.ARCHIVE_SERIAL_NUMBER_MIN:,}, "
f"{Document.ARCHIVE_SERIAL_NUMBER_MAX:,}.",
)
asn = 0
users_with_perms = get_users_with_perms(
doc,
only_with_perms_in=["view_document"],
)
viewer_ids = ",".join([str(u.id) for u in users_with_perms])
writer.update_document(
id=doc.pk,
title=doc.title,
content=doc.content,
correspondent=doc.correspondent.name if doc.correspondent else None,
correspondent_id=doc.correspondent.id if doc.correspondent else None,
has_correspondent=doc.correspondent is not None,
tag=tags if tags else None,
tag_id=tags_ids if tags_ids else None,
has_tag=len(tags) > 0,
type=doc.document_type.name if doc.document_type else None,
type_id=doc.document_type.id if doc.document_type else None,
has_type=doc.document_type is not None,
created=doc.created,
added=doc.added,
asn=asn,
modified=doc.modified,
path=doc.storage_path.name if doc.storage_path else None,
path_id=doc.storage_path.id if doc.storage_path else None,
has_path=doc.storage_path is not None,
notes=notes,
# metadatas=metadatas,
owner=doc.owner.username if doc.owner else None,
owner_id=doc.owner.id if doc.owner else None,
has_owner=doc.owner is not None,
viewer_id=viewer_ids if viewer_ids else None,
)
def remove_document(writer, doc):
remove_document_by_id(writer, doc.pk)
def remove_document_by_id(writer, doc_id):
writer.delete_by_term("id", doc_id)
def add_or_update_document(document):
with open_index_writer() as writer:
update_document(writer, document)
def remove_document_from_index(document):
with open_index_writer() as writer:
remove_document(writer, document)
class DelayedQuery:
def _get_query(self):
raise NotImplementedError
def _get_query_filter(self):
criterias = []
for k, v in self.query_params.items():
if k == "correspondent__id":
criterias.append(query.Term("correspondent_id", v))
elif k == "tags__id__all":
for tag_id in v.split(","):
criterias.append(query.Term("tag_id", tag_id))
elif k == "tags__id__none":
for tag_id in v.split(","):
criterias.append(query.Not(query.Term("tag_id", tag_id)))
elif k == "document_type__id":
criterias.append(query.Term("type_id", v))
elif k == "correspondent__isnull":
criterias.append(query.Term("has_correspondent", v == "false"))
elif k == "is_tagged":
criterias.append(query.Term("has_tag", v == "true"))
elif k == "document_type__isnull":
criterias.append(query.Term("has_type", v == "false"))
elif k == "created__date__lt":
criterias.append(
query.DateRange("created", start=None, end=isoparse(v)),
)
elif k == "created__date__gt":
criterias.append(
query.DateRange("created", start=isoparse(v), end=None),
)
elif k == "added__date__gt":
criterias.append(query.DateRange("added", start=isoparse(v), end=None))
elif k == "added__date__lt":
criterias.append(query.DateRange("added", start=None, end=isoparse(v)))
elif k == "storage_path__id":
criterias.append(query.Term("path_id", v))
elif k == "storage_path__isnull":
criterias.append(query.Term("has_path", v == "false"))
user_criterias = [query.Term("has_owner", False)]
if "user" in self.query_params:
user_criterias.append(query.Term("owner_id", self.query_params["user"]))
user_criterias.append(
query.Term("viewer_id", str(self.query_params["user"])),
)
if len(criterias) > 0:
criterias.append(query.Or(user_criterias))
return query.And(criterias)
else:
return query.Or(user_criterias)
def _get_query_sortedby(self):
if "ordering" not in self.query_params:
return None, False
field: str = self.query_params["ordering"]
sort_fields_map = {
"created": "created",
"modified": "modified",
"added": "added",
"title": "title",
"correspondent__name": "correspondent",
"document_type__name": "type",
"archive_serial_number": "asn",
}
if field.startswith("-"):
field = field[1:]
reverse = True
else:
reverse = False
if field not in sort_fields_map:
return None, False
else:
return sort_fields_map[field], reverse
def __init__(self, searcher: Searcher, query_params, page_size):
self.searcher = searcher
self.query_params = query_params
self.page_size = page_size
self.saved_results = dict()
self.first_score = None
def __len__(self):
page = self[0:1]
return len(page)
def __getitem__(self, item):
if item.start in self.saved_results:
return self.saved_results[item.start]
q, mask = self._get_query()
sortedby, reverse = self._get_query_sortedby()
page: ResultsPage = self.searcher.search_page(
q,
mask=mask,
filter=self._get_query_filter(),
pagenum=math.floor(item.start / self.page_size) + 1,
pagelen=self.page_size,
sortedby=sortedby,
reverse=reverse,
)
page.results.fragmenter = highlight.ContextFragmenter(surround=50)
page.results.formatter = HtmlFormatter(tagname="span", between=" ... ")
if not self.first_score and len(page.results) > 0 and sortedby is None:
self.first_score = page.results[0].score
page.results.top_n = list(
map(
lambda hit: (
(hit[0] / self.first_score) if self.first_score else None,
hit[1],
),
page.results.top_n,
),
)
self.saved_results[item.start] = page
return page
class DelayedFullTextQuery(DelayedQuery):
def _get_query(self):
q_str = self.query_params["query"]
qp = MultifieldParser(
["content", "title", "correspondent", "tag", "type", "notes", "metadatas"],
self.searcher.ixreader.schema,
)
qp.add_plugin(DateParserPlugin(basedate=timezone.now()))
q = qp.parse(q_str)
corrected = self.searcher.correct_query(q, q_str)
if corrected.query != q:
corrected.query = corrected.string
return q, None
class DelayedMoreLikeThisQuery(DelayedQuery):
def _get_query(self):
more_like_doc_id = int(self.query_params["more_like_id"])
content = Document.objects.get(id=more_like_doc_id).content
docnum = self.searcher.document_number(id=more_like_doc_id)
kts = self.searcher.key_terms_from_text(
"content",
content,
numterms=20,
model=classify.Bo1Model,
normalize=False,
)
q = query.Or(
[query.Term("content", word, boost=weight) for word, weight in kts],
)
mask = {docnum}
return q, mask
def autocomplete(ix, term, limit=10):
with ix.reader() as reader:
terms = []
for (score, t) in reader.most_distinctive_terms(
"content",
number=limit,
prefix=term.lower(),
):
terms.append(t)
return terms

View File

@ -1,69 +1,69 @@
from django.db import migrations, models
import django.utils.timezone
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
("documents", "1035_rename_comment_note"),
]
operations = [
migrations.CreateModel(
name="Metadata",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"data",
models.JSONField(
blank=True,
help_text="JSON metadata",
verbose_name="data"
),
),
(
"created",
models.DateTimeField(
db_index=True,
default=django.utils.timezone.now,
verbose_name="created",
),
),
(
"document",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="metadatas",
to="documents.document",
verbose_name="document",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="metadatas",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
options={
"verbose_name": "metadata",
"verbose_name_plural": "metadatas",
"ordering": ("created",),
},
),
]
from django.db import migrations, models
import django.utils.timezone
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
("documents", "1035_rename_comment_note"),
]
operations = [
migrations.CreateModel(
name="Metadata",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"data",
models.JSONField(
blank=True,
help_text="JSON metadata",
verbose_name="data"
),
),
(
"created",
models.DateTimeField(
db_index=True,
default=django.utils.timezone.now,
verbose_name="created",
),
),
(
"document",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="metadatas",
to="documents.document",
verbose_name="document",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="metadatas",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
options={
"verbose_name": "metadata",
"verbose_name_plural": "metadatas",
"ordering": ("created",),
},
),
]

View File

@ -1,24 +1,24 @@
# Generated by Django 4.1.7 on 2023-07-23 17:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('documents', '1036_add_metadata'),
]
operations = [
migrations.AddField(
model_name='documenttype',
name='default_metadata',
field=models.JSONField(blank=True, help_text='Default JSON metadata', null=True, verbose_name='default_metadata'),
),
migrations.AlterField(
model_name='metadata',
name='document',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='document', to='documents.document', verbose_name='document'),
),
# Generated by Django 4.1.7 on 2023-07-23 17:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('documents', '1036_add_metadata'),
]
operations = [
migrations.AddField(
model_name='documenttype',
name='default_metadata',
field=models.JSONField(blank=True, help_text='Default JSON metadata', null=True, verbose_name='default_metadata'),
),
migrations.AlterField(
model_name='metadata',
name='document',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='document', to='documents.document', verbose_name='document'),
),
]

View File

@ -1,19 +1,19 @@
# Generated by Django 4.1.7 on 2023-07-27 02:44
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('documents', '1037_alter_documenttype_add_default_metadata'),
]
operations = [
migrations.AlterField(
model_name='metadata',
name='document',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='metadatas', to='documents.document', verbose_name='document'),
),
# Generated by Django 4.1.7 on 2023-07-27 02:44
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('documents', '1037_alter_documenttype_add_default_metadata'),
]
operations = [
migrations.AlterField(
model_name='metadata',
name='document',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='metadatas', to='documents.document', verbose_name='document'),
),
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,305 +1,305 @@
import hashlib
import logging
import shutil
import uuid
from typing import Optional
from typing import Type
import tqdm
from asgiref.sync import async_to_sync
from celery import shared_task
from channels.layers import get_channel_layer
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_save
from documents import barcodes
from documents import index
from documents import sanity_checker
from documents.classifier import DocumentClassifier
from documents.classifier import load_classifier
from documents.consumer import Consumer
from documents.consumer import ConsumerError
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.parsers import DocumentParser
from documents.parsers import get_parser_class_for_mime_type
from documents.sanity_checker import SanityCheckFailedException
from filelock import FileLock
from redis.exceptions import ConnectionError
from whoosh.writing import AsyncWriter
logger = logging.getLogger("paperless.tasks")
@shared_task
def index_optimize():
ix = index.open_index()
writer = AsyncWriter(ix)
writer.commit(optimize=True)
def index_reindex(progress_bar_disable=False):
documents = Document.objects.all()
ix = index.open_index(recreate=True)
with AsyncWriter(ix) as writer:
for document in tqdm.tqdm(documents, disable=progress_bar_disable):
index.update_document(writer, document)
@shared_task
def train_classifier():
if (
not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not DocumentType.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not Correspondent.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not StoragePath.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
):
return
classifier = load_classifier()
if not classifier:
classifier = DocumentClassifier()
try:
if classifier.train():
logger.info(
f"Saving updated classifier model to {settings.MODEL_FILE}...",
)
classifier.save()
else:
logger.debug("Training data unchanged.")
except Exception as e:
logger.warning("Classifier error: " + str(e))
@shared_task
def consume_file(
input_doc: ConsumableDocument,
overrides: Optional[DocumentMetadataOverrides] = None,
):
# Default no overrides
if overrides is None:
overrides = DocumentMetadataOverrides()
# read all barcodes in the current document
if settings.CONSUMER_ENABLE_BARCODES or settings.CONSUMER_ENABLE_ASN_BARCODE:
doc_barcode_info = barcodes.scan_file_for_barcodes(
input_doc.original_file,
input_doc.mime_type,
)
# split document by separator pages, if enabled
if settings.CONSUMER_ENABLE_BARCODES:
separators = barcodes.get_separating_barcodes(doc_barcode_info.barcodes)
if len(separators) > 0:
logger.debug(
f"Pages with separators found in: {input_doc.original_file}",
)
document_list = barcodes.separate_pages(
doc_barcode_info.pdf_path,
separators,
)
if document_list:
# If the file is an upload, it's in the scratch directory
# Move it to consume directory to be picked up
# Otherwise, use the current parent to keep possible tags
# from subdirectories
if input_doc.source != DocumentSource.ConsumeFolder:
save_to_dir = settings.CONSUMPTION_DIR
else:
# Note this uses the original file, because it's in the
# consume folder already and may include additional path
# components for tagging
# the .path is somewhere in scratch in this case
save_to_dir = input_doc.original_file.parent
for n, document in enumerate(document_list):
# save to consumption dir
# rename it to the original filename with number prefix
if overrides.filename is not None:
newname = f"{str(n)}_{overrides.filename}"
else:
newname = None
barcodes.save_to_dir(
document,
newname=newname,
target_dir=save_to_dir,
)
# Split file has been copied safely, remove it
document.unlink()
# And clean up the directory as well, now it's empty
shutil.rmtree(document_list[0].parent)
# This file has been split into multiple files without issue
# remove the original and working copy
input_doc.original_file.unlink()
# If the original file was a TIFF, remove the PDF generated from it
if input_doc.mime_type == "image/tiff":
logger.debug(
f"Deleting file {doc_barcode_info.pdf_path}",
)
doc_barcode_info.pdf_path.unlink()
# notify the sender, otherwise the progress bar
# in the UI stays stuck
payload = {
"filename": overrides.filename or input_doc.original_file.name,
"task_id": None,
"current_progress": 100,
"max_progress": 100,
"status": "SUCCESS",
"message": "finished",
}
try:
async_to_sync(get_channel_layer().group_send)(
"status_updates",
{"type": "status_update", "data": payload},
)
except ConnectionError as e:
logger.warning(f"ConnectionError on status send: {str(e)}")
# consuming stops here, since the original document with
# the barcodes has been split and will be consumed separately
return "File successfully split"
# try reading the ASN from barcode
if settings.CONSUMER_ENABLE_ASN_BARCODE:
overrides.asn = barcodes.get_asn_from_barcodes(doc_barcode_info.barcodes)
if overrides.asn:
logger.info(f"Found ASN in barcode: {overrides.asn}")
# continue with consumption if no barcode was found
document = Consumer().try_consume_file(
input_doc.original_file,
override_filename=overrides.filename,
override_title=overrides.title,
override_correspondent_id=overrides.correspondent_id,
override_document_type_id=overrides.document_type_id,
override_tag_ids=overrides.tag_ids,
override_created=overrides.created,
override_asn=overrides.asn,
override_owner_id=overrides.owner_id,
override_storage_path_id=overrides.storage_path_id,
full_path=overrides.full_path
)
if document:
return f"Success. New document id {document.pk} created"
else:
raise ConsumerError(
"Unknown error: Returned document was null, but "
"no error message was given.",
)
@shared_task
def sanity_check():
messages = sanity_checker.check_sanity()
messages.log_messages()
if messages.has_error:
raise SanityCheckFailedException("Sanity check failed with errors. See log.")
elif messages.has_warning:
return "Sanity check exited with warnings. See log."
elif len(messages) > 0:
return "Sanity check exited with infos. See log."
else:
return "No issues detected."
@shared_task
def bulk_update_documents(document_ids):
documents = Document.objects.filter(id__in=document_ids)
ix = index.open_index()
for doc in documents:
post_save.send(Document, instance=doc, created=False)
with AsyncWriter(ix) as writer:
for doc in documents:
index.update_document(writer, doc)
@shared_task
def update_document_archive_file(document_id):
"""
Re-creates the archive file of a document, including new OCR content and thumbnail
"""
document = Document.objects.get(id=document_id)
mime_type = document.mime_type
parser_class: Type[DocumentParser] = get_parser_class_for_mime_type(mime_type)
if not parser_class:
logger.error(
f"No parser found for mime type {mime_type}, cannot "
f"archive document {document} (ID: {document_id})",
)
return
parser: DocumentParser = parser_class(logging_group=uuid.uuid4())
try:
parser.parse(document.source_path, mime_type, document.get_public_filename())
thumbnail = parser.get_thumbnail(
document.source_path,
mime_type,
document.get_public_filename(),
)
if parser.get_archive_path():
with transaction.atomic():
with open(parser.get_archive_path(), "rb") as f:
checksum = hashlib.md5(f.read()).hexdigest()
# I'm going to save first so that in case the file move
# fails, the database is rolled back.
# We also don't use save() since that triggers the filehandling
# logic, and we don't want that yet (file not yet in place)
document.archive_filename = generate_unique_filename(
document,
archive_filename=True,
)
Document.objects.filter(pk=document.pk).update(
archive_checksum=checksum,
content=parser.get_text(),
archive_filename=document.archive_filename,
)
with FileLock(settings.MEDIA_LOCK):
create_source_path_directory(document.archive_path)
shutil.move(parser.get_archive_path(), document.archive_path)
shutil.move(thumbnail, document.thumbnail_path)
with index.open_index_writer() as writer:
index.update_document(writer, document)
except Exception:
logger.exception(
f"Error while parsing document {document} (ID: {document_id})",
)
finally:
parser.cleanup()
import hashlib
import logging
import shutil
import uuid
from typing import Optional
from typing import Type
import tqdm
from asgiref.sync import async_to_sync
from celery import shared_task
from channels.layers import get_channel_layer
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_save
from documents import barcodes
from documents import index
from documents import sanity_checker
from documents.classifier import DocumentClassifier
from documents.classifier import load_classifier
from documents.consumer import Consumer
from documents.consumer import ConsumerError
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.parsers import DocumentParser
from documents.parsers import get_parser_class_for_mime_type
from documents.sanity_checker import SanityCheckFailedException
from filelock import FileLock
from redis.exceptions import ConnectionError
from whoosh.writing import AsyncWriter
logger = logging.getLogger("paperless.tasks")
@shared_task
def index_optimize():
ix = index.open_index()
writer = AsyncWriter(ix)
writer.commit(optimize=True)
def index_reindex(progress_bar_disable=False):
documents = Document.objects.all()
ix = index.open_index(recreate=True)
with AsyncWriter(ix) as writer:
for document in tqdm.tqdm(documents, disable=progress_bar_disable):
index.update_document(writer, document)
@shared_task
def train_classifier():
if (
not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not DocumentType.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not Correspondent.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not StoragePath.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
):
return
classifier = load_classifier()
if not classifier:
classifier = DocumentClassifier()
try:
if classifier.train():
logger.info(
f"Saving updated classifier model to {settings.MODEL_FILE}...",
)
classifier.save()
else:
logger.debug("Training data unchanged.")
except Exception as e:
logger.warning("Classifier error: " + str(e))
@shared_task
def consume_file(
input_doc: ConsumableDocument,
overrides: Optional[DocumentMetadataOverrides] = None,
):
# Default no overrides
if overrides is None:
overrides = DocumentMetadataOverrides()
# read all barcodes in the current document
if settings.CONSUMER_ENABLE_BARCODES or settings.CONSUMER_ENABLE_ASN_BARCODE:
doc_barcode_info = barcodes.scan_file_for_barcodes(
input_doc.original_file,
input_doc.mime_type,
)
# split document by separator pages, if enabled
if settings.CONSUMER_ENABLE_BARCODES:
separators = barcodes.get_separating_barcodes(doc_barcode_info.barcodes)
if len(separators) > 0:
logger.debug(
f"Pages with separators found in: {input_doc.original_file}",
)
document_list = barcodes.separate_pages(
doc_barcode_info.pdf_path,
separators,
)
if document_list:
# If the file is an upload, it's in the scratch directory
# Move it to consume directory to be picked up
# Otherwise, use the current parent to keep possible tags
# from subdirectories
if input_doc.source != DocumentSource.ConsumeFolder:
save_to_dir = settings.CONSUMPTION_DIR
else:
# Note this uses the original file, because it's in the
# consume folder already and may include additional path
# components for tagging
# the .path is somewhere in scratch in this case
save_to_dir = input_doc.original_file.parent
for n, document in enumerate(document_list):
# save to consumption dir
# rename it to the original filename with number prefix
if overrides.filename is not None:
newname = f"{str(n)}_{overrides.filename}"
else:
newname = None
barcodes.save_to_dir(
document,
newname=newname,
target_dir=save_to_dir,
)
# Split file has been copied safely, remove it
document.unlink()
# And clean up the directory as well, now it's empty
shutil.rmtree(document_list[0].parent)
# This file has been split into multiple files without issue
# remove the original and working copy
input_doc.original_file.unlink()
# If the original file was a TIFF, remove the PDF generated from it
if input_doc.mime_type == "image/tiff":
logger.debug(
f"Deleting file {doc_barcode_info.pdf_path}",
)
doc_barcode_info.pdf_path.unlink()
# notify the sender, otherwise the progress bar
# in the UI stays stuck
payload = {
"filename": overrides.filename or input_doc.original_file.name,
"task_id": None,
"current_progress": 100,
"max_progress": 100,
"status": "SUCCESS",
"message": "finished",
}
try:
async_to_sync(get_channel_layer().group_send)(
"status_updates",
{"type": "status_update", "data": payload},
)
except ConnectionError as e:
logger.warning(f"ConnectionError on status send: {str(e)}")
# consuming stops here, since the original document with
# the barcodes has been split and will be consumed separately
return "File successfully split"
# try reading the ASN from barcode
if settings.CONSUMER_ENABLE_ASN_BARCODE:
overrides.asn = barcodes.get_asn_from_barcodes(doc_barcode_info.barcodes)
if overrides.asn:
logger.info(f"Found ASN in barcode: {overrides.asn}")
# continue with consumption if no barcode was found
document = Consumer().try_consume_file(
input_doc.original_file,
override_filename=overrides.filename,
override_title=overrides.title,
override_correspondent_id=overrides.correspondent_id,
override_document_type_id=overrides.document_type_id,
override_tag_ids=overrides.tag_ids,
override_created=overrides.created,
override_asn=overrides.asn,
override_owner_id=overrides.owner_id,
override_storage_path_id=overrides.storage_path_id,
full_path=overrides.full_path
)
if document:
return f"Success. New document id {document.pk} created"
else:
raise ConsumerError(
"Unknown error: Returned document was null, but "
"no error message was given.",
)
@shared_task
def sanity_check():
messages = sanity_checker.check_sanity()
messages.log_messages()
if messages.has_error:
raise SanityCheckFailedException("Sanity check failed with errors. See log.")
elif messages.has_warning:
return "Sanity check exited with warnings. See log."
elif len(messages) > 0:
return "Sanity check exited with infos. See log."
else:
return "No issues detected."
@shared_task
def bulk_update_documents(document_ids):
documents = Document.objects.filter(id__in=document_ids)
ix = index.open_index()
for doc in documents:
post_save.send(Document, instance=doc, created=False)
with AsyncWriter(ix) as writer:
for doc in documents:
index.update_document(writer, doc)
@shared_task
def update_document_archive_file(document_id):
"""
Re-creates the archive file of a document, including new OCR content and thumbnail
"""
document = Document.objects.get(id=document_id)
mime_type = document.mime_type
parser_class: Type[DocumentParser] = get_parser_class_for_mime_type(mime_type)
if not parser_class:
logger.error(
f"No parser found for mime type {mime_type}, cannot "
f"archive document {document} (ID: {document_id})",
)
return
parser: DocumentParser = parser_class(logging_group=uuid.uuid4())
try:
parser.parse(document.source_path, mime_type, document.get_public_filename())
thumbnail = parser.get_thumbnail(
document.source_path,
mime_type,
document.get_public_filename(),
)
if parser.get_archive_path():
with transaction.atomic():
with open(parser.get_archive_path(), "rb") as f:
checksum = hashlib.md5(f.read()).hexdigest()
# I'm going to save first so that in case the file move
# fails, the database is rolled back.
# We also don't use save() since that triggers the filehandling
# logic, and we don't want that yet (file not yet in place)
document.archive_filename = generate_unique_filename(
document,
archive_filename=True,
)
Document.objects.filter(pk=document.pk).update(
archive_checksum=checksum,
content=parser.get_text(),
archive_filename=document.archive_filename,
)
with FileLock(settings.MEDIA_LOCK):
create_source_path_directory(document.archive_path)
shutil.move(parser.get_archive_path(), document.archive_path)
shutil.move(thumbnail, document.thumbnail_path)
with index.open_index_writer() as writer:
index.update_document(writer, document)
except Exception:
logger.exception(
f"Error while parsing document {document} (ID: {document_id})",
)
finally:
parser.cleanup()

View File

@ -1,48 +1,48 @@
<!DOCTYPE html>
{% load static %} {% load i18n %}
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors" />
<meta name="generator" content="Jekyll v4.1.1" />
<meta name="robots" content="noindex,nofollow" />
<title>{% translate "Paperless-ngx signed out" %}</title>
<!-- Bootstrap core CSS -->
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet" />
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
<!-- Custom styles for this template -->
<link href="{% static 'signin.css' %}" rel="stylesheet" />
</head>
<body class="text-center">
<div class="form-signin">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" version="1.0" viewBox="0 0 1200 1056">
<path d="M472.5 33c-125.3 3.8-199.4 13.6-264 35.1-26.9 9-56.6 22.8-73.2 34.1C84.7 136.6 50 197.3 40.4 268c-2.3 17-2.3 50.1 0 67 13.8 101.7 73 239.1 167.9 390 47.6 75.7 114.1 165.4 148.5 200.4 57.4 58.3 107.8 89.2 161.7 99.1 12.2 2.3 41.3 3.1 54.5 1.6 51.4-6 112.1-31 169-69.9 70.5-48.1 154-128.9 224.6-217.4 35.6-44.5 73.3-100.3 96.7-142.8 45.7-83 74.9-156.9 86.1-218 6-32.7 8.4-66.5 5.9-83.5-3.7-25.6-10-46.3-20.3-67-9.3-18.7-17.8-30.3-34-46.6-23.9-24.1-46.9-40-86.6-59.8-89.2-44.6-214.8-74.5-354.9-84.6-51.9-3.7-131-5.2-187-3.5zm142 45c78.7 4.4 133.6 11.5 198.5 25.6 118.2 25.6 208.7 66.2 253.6 113.7 38.7 40.8 46 101.7 22.3 184.2-15.6 54.1-48.4 122.6-93.1 194.4-15.4 24.7-39 60.1-50.1 75.1-35.6 48.1-90.2 107.6-141.5 154.2-78.4 71.4-152.7 118-210.7 132.2-20 4.9-21.5 5.1-42 5.1-22.6 0-29.3-1.2-47.2-8.5-35-14.3-87.9-57.1-132.4-107-21.7-24.4-36.7-47.5-42.9-66.4-3.9-12-2.6-20.3 3.6-22.6 11.3-4.3 33.8 3.6 73.6 25.8 52 29.1 72.5 37.1 112.9 44.6 70.9 13.1 135.2 8.7 197.4-13.4 30-10.7 64.7-29.7 92-50.5 37-28.2 73.8-68.3 101.3-110.3 7.4-11.3 23.7-42 22.8-42.9-.2-.2-2.6.7-5.3 2-15.9 8.1-47.4 13.7-76.9 13.7-41.6 0-76.1-8.2-98.4-23.3-6.7-4.6-16.7-14.4-21.5-21.2-16.7-23.5-24.4-59.3-23.2-107.9 1.7-71.3 23.6-113.8 68.6-133.2 17.9-7.8 37-10.7 70.1-10.7 26-.1 36.6 1.2 51.7 6.3 15.2 5.1 26.1 13.8 31.7 25.3 2.6 5.3 9.6 28.4 9.6 31.6 0 .9-1.3 2.9-2.8 4.4-2.5 2.4-3.8 2.8-11.8 3.3-6.8.5-10.4.2-15.4-1.1-11.5-3-23.1-4.6-39-5.2-43.5-1.7-64.5 9-76 38.8-10.4 26.9-10.4 73.7 0 95.4 8.1 17 25.3 28.1 49.3 32 10.6 1.7 36.4 2 50.7.5 16.2-1.7 56.2-8.4 57.5-9.7 1.2-1.2 5.7-27.3 7.5-44.3 3.9-36.1 1.5-83.2-6.2-120.5-15.9-77.9-57.3-150.3-113.4-198.5-24.5-21.1-45.7-35.1-74.9-49.5-45.5-22.4-85.7-34.8-132.5-40.7-16-2-64.1-1.7-80 .6-36.9 5.2-63.5 12.5-104.5 28.8C377.2 156.3 335 189.8 265.6 273c-28.5 34.2-42.5 47-61.9 56.5-13.2 6.5-22.5 8.7-37.2 8.7-18.5 0-28.6-4.1-40.2-16.6-22.1-23.6-26.6-68.3-10.8-108.1 16.9-42.5 50-72.6 102.5-92.8 60.4-23.3 167.3-40.1 281-44.1 17.5-.6 97.3.3 115.5 1.4zm-342 254c20 1.9 24 4.1 30.5 17l3 5.9V557h147.8l.7 3.1c.9 4-.2 14.8-2.1 21.9-2.5 8.7-7 16.1-14.2 23.3-8.1 8-16 12.9-26.7 16.5l-8 2.7-85.6.3-85.6.3-.7-43.3c-.3-23.8-.9-90-1.2-147.1l-.7-103.7h15.9c8.7 0 20.9.5 26.9 1zm338.1 3.5c24.2 3.9 42.1 13 57.3 29.1 8.1 8.5 14.8 19.6 17.1 28.4.7 2.5 1.2 8.4 1.1 13.2-.2 25.4-12.6 45.7-34.8 56.9l-8.2 4.2 6.5 2.3c8 2.8 16.2 7.3 23.6 13.2 17.9 14 26 37.4 23 66.2-1.9 18-6.8 27.5-22.2 43.1-9.1 9.2-11.7 11.2-19 14.7-9.1 4.4-21.3 8.1-35 10.8-7.3 1.5-18.6 1.8-82.2 2.1l-73.8.4V503.3c0-127.7-.3-121.4 6-133.1 3.7-7 13.1-18.5 18.2-22.4 7-5.3 17.6-9.9 28.8-12.4 8.6-1.9 81.8-1.8 93.6.1z" />
<path d="M537.4 393.8c-2.9 1.9-3.4 5.5-3.4 26.6V442h30.3c34 0 34.6-.2 42.1-7.6 9.9-9.9 10.2-22.9.8-32.8-2-2.1-5.9-4.9-8.7-6.2-4.9-2.3-5.6-2.4-32.5-2.4-15.1 0-28 .4-28.6.8zM534 530.1v32l32.3-.3c36-.5 38-.8 47.7-7.5 3.1-2.2 6.7-5.9 8.5-8.7 2.8-4.7 3-5.5 3-15.5 0-9.9-.2-11-3-16-4-7.2-9.5-11.7-17.3-14.1-5.8-1.8-9.4-2-38.8-2H534v32.1z" />
</svg>
<p>{% translate "You have been successfully logged out. Bye!" %}</p>
<a href="{% url 'base' %}">{% translate "Sign in again" %}</a>
</div>
</body>
</html>
<!DOCTYPE html>
{% load static %} {% load i18n %}
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors" />
<meta name="generator" content="Jekyll v4.1.1" />
<meta name="robots" content="noindex,nofollow" />
<title>{% translate "Paperless-ngx signed out" %}</title>
<!-- Bootstrap core CSS -->
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet" />
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
<!-- Custom styles for this template -->
<link href="{% static 'signin.css' %}" rel="stylesheet" />
</head>
<body class="text-center">
<div class="form-signin">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" version="1.0" viewBox="0 0 1200 1056">
<path d="M472.5 33c-125.3 3.8-199.4 13.6-264 35.1-26.9 9-56.6 22.8-73.2 34.1C84.7 136.6 50 197.3 40.4 268c-2.3 17-2.3 50.1 0 67 13.8 101.7 73 239.1 167.9 390 47.6 75.7 114.1 165.4 148.5 200.4 57.4 58.3 107.8 89.2 161.7 99.1 12.2 2.3 41.3 3.1 54.5 1.6 51.4-6 112.1-31 169-69.9 70.5-48.1 154-128.9 224.6-217.4 35.6-44.5 73.3-100.3 96.7-142.8 45.7-83 74.9-156.9 86.1-218 6-32.7 8.4-66.5 5.9-83.5-3.7-25.6-10-46.3-20.3-67-9.3-18.7-17.8-30.3-34-46.6-23.9-24.1-46.9-40-86.6-59.8-89.2-44.6-214.8-74.5-354.9-84.6-51.9-3.7-131-5.2-187-3.5zm142 45c78.7 4.4 133.6 11.5 198.5 25.6 118.2 25.6 208.7 66.2 253.6 113.7 38.7 40.8 46 101.7 22.3 184.2-15.6 54.1-48.4 122.6-93.1 194.4-15.4 24.7-39 60.1-50.1 75.1-35.6 48.1-90.2 107.6-141.5 154.2-78.4 71.4-152.7 118-210.7 132.2-20 4.9-21.5 5.1-42 5.1-22.6 0-29.3-1.2-47.2-8.5-35-14.3-87.9-57.1-132.4-107-21.7-24.4-36.7-47.5-42.9-66.4-3.9-12-2.6-20.3 3.6-22.6 11.3-4.3 33.8 3.6 73.6 25.8 52 29.1 72.5 37.1 112.9 44.6 70.9 13.1 135.2 8.7 197.4-13.4 30-10.7 64.7-29.7 92-50.5 37-28.2 73.8-68.3 101.3-110.3 7.4-11.3 23.7-42 22.8-42.9-.2-.2-2.6.7-5.3 2-15.9 8.1-47.4 13.7-76.9 13.7-41.6 0-76.1-8.2-98.4-23.3-6.7-4.6-16.7-14.4-21.5-21.2-16.7-23.5-24.4-59.3-23.2-107.9 1.7-71.3 23.6-113.8 68.6-133.2 17.9-7.8 37-10.7 70.1-10.7 26-.1 36.6 1.2 51.7 6.3 15.2 5.1 26.1 13.8 31.7 25.3 2.6 5.3 9.6 28.4 9.6 31.6 0 .9-1.3 2.9-2.8 4.4-2.5 2.4-3.8 2.8-11.8 3.3-6.8.5-10.4.2-15.4-1.1-11.5-3-23.1-4.6-39-5.2-43.5-1.7-64.5 9-76 38.8-10.4 26.9-10.4 73.7 0 95.4 8.1 17 25.3 28.1 49.3 32 10.6 1.7 36.4 2 50.7.5 16.2-1.7 56.2-8.4 57.5-9.7 1.2-1.2 5.7-27.3 7.5-44.3 3.9-36.1 1.5-83.2-6.2-120.5-15.9-77.9-57.3-150.3-113.4-198.5-24.5-21.1-45.7-35.1-74.9-49.5-45.5-22.4-85.7-34.8-132.5-40.7-16-2-64.1-1.7-80 .6-36.9 5.2-63.5 12.5-104.5 28.8C377.2 156.3 335 189.8 265.6 273c-28.5 34.2-42.5 47-61.9 56.5-13.2 6.5-22.5 8.7-37.2 8.7-18.5 0-28.6-4.1-40.2-16.6-22.1-23.6-26.6-68.3-10.8-108.1 16.9-42.5 50-72.6 102.5-92.8 60.4-23.3 167.3-40.1 281-44.1 17.5-.6 97.3.3 115.5 1.4zm-342 254c20 1.9 24 4.1 30.5 17l3 5.9V557h147.8l.7 3.1c.9 4-.2 14.8-2.1 21.9-2.5 8.7-7 16.1-14.2 23.3-8.1 8-16 12.9-26.7 16.5l-8 2.7-85.6.3-85.6.3-.7-43.3c-.3-23.8-.9-90-1.2-147.1l-.7-103.7h15.9c8.7 0 20.9.5 26.9 1zm338.1 3.5c24.2 3.9 42.1 13 57.3 29.1 8.1 8.5 14.8 19.6 17.1 28.4.7 2.5 1.2 8.4 1.1 13.2-.2 25.4-12.6 45.7-34.8 56.9l-8.2 4.2 6.5 2.3c8 2.8 16.2 7.3 23.6 13.2 17.9 14 26 37.4 23 66.2-1.9 18-6.8 27.5-22.2 43.1-9.1 9.2-11.7 11.2-19 14.7-9.1 4.4-21.3 8.1-35 10.8-7.3 1.5-18.6 1.8-82.2 2.1l-73.8.4V503.3c0-127.7-.3-121.4 6-133.1 3.7-7 13.1-18.5 18.2-22.4 7-5.3 17.6-9.9 28.8-12.4 8.6-1.9 81.8-1.8 93.6.1z" />
<path d="M537.4 393.8c-2.9 1.9-3.4 5.5-3.4 26.6V442h30.3c34 0 34.6-.2 42.1-7.6 9.9-9.9 10.2-22.9.8-32.8-2-2.1-5.9-4.9-8.7-6.2-4.9-2.3-5.6-2.4-32.5-2.4-15.1 0-28 .4-28.6.8zM534 530.1v32l32.3-.3c36-.5 38-.8 47.7-7.5 3.1-2.2 6.7-5.9 8.5-8.7 2.8-4.7 3-5.5 3-15.5 0-9.9-.2-11-3-16-4-7.2-9.5-11.7-17.3-14.1-5.8-1.8-9.4-2-38.8-2H534v32.1z" />
</svg>
<p>{% translate "You have been successfully logged out. Bye!" %}</p>
<a href="{% url 'base' %}">{% translate "Sign in again" %}</a>
</div>
</body>
</html>

View File

@ -1,57 +1,57 @@
<!DOCTYPE html>
{% load static %} {% load i18n %}
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors" />
<meta name="generator" content="Jekyll v4.1.1" />
<meta name="robots" content="noindex,nofollow" />
<title>{% translate "LBC Finance sign in" %}</title>
<!-- Bootstrap core CSS -->
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet" />
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
<!-- Custom styles for this template -->
<link href="{% static 'signin.css' %}" rel="stylesheet" />
</head>
<body class="text-center">
<form class="form-signin" method="post">
{% csrf_token %}
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" version="1.0" viewBox="0 0 1200 1056">
<path d="M472.5 33c-125.3 3.8-199.4 13.6-264 35.1-26.9 9-56.6 22.8-73.2 34.1C84.7 136.6 50 197.3 40.4 268c-2.3 17-2.3 50.1 0 67 13.8 101.7 73 239.1 167.9 390 47.6 75.7 114.1 165.4 148.5 200.4 57.4 58.3 107.8 89.2 161.7 99.1 12.2 2.3 41.3 3.1 54.5 1.6 51.4-6 112.1-31 169-69.9 70.5-48.1 154-128.9 224.6-217.4 35.6-44.5 73.3-100.3 96.7-142.8 45.7-83 74.9-156.9 86.1-218 6-32.7 8.4-66.5 5.9-83.5-3.7-25.6-10-46.3-20.3-67-9.3-18.7-17.8-30.3-34-46.6-23.9-24.1-46.9-40-86.6-59.8-89.2-44.6-214.8-74.5-354.9-84.6-51.9-3.7-131-5.2-187-3.5zm142 45c78.7 4.4 133.6 11.5 198.5 25.6 118.2 25.6 208.7 66.2 253.6 113.7 38.7 40.8 46 101.7 22.3 184.2-15.6 54.1-48.4 122.6-93.1 194.4-15.4 24.7-39 60.1-50.1 75.1-35.6 48.1-90.2 107.6-141.5 154.2-78.4 71.4-152.7 118-210.7 132.2-20 4.9-21.5 5.1-42 5.1-22.6 0-29.3-1.2-47.2-8.5-35-14.3-87.9-57.1-132.4-107-21.7-24.4-36.7-47.5-42.9-66.4-3.9-12-2.6-20.3 3.6-22.6 11.3-4.3 33.8 3.6 73.6 25.8 52 29.1 72.5 37.1 112.9 44.6 70.9 13.1 135.2 8.7 197.4-13.4 30-10.7 64.7-29.7 92-50.5 37-28.2 73.8-68.3 101.3-110.3 7.4-11.3 23.7-42 22.8-42.9-.2-.2-2.6.7-5.3 2-15.9 8.1-47.4 13.7-76.9 13.7-41.6 0-76.1-8.2-98.4-23.3-6.7-4.6-16.7-14.4-21.5-21.2-16.7-23.5-24.4-59.3-23.2-107.9 1.7-71.3 23.6-113.8 68.6-133.2 17.9-7.8 37-10.7 70.1-10.7 26-.1 36.6 1.2 51.7 6.3 15.2 5.1 26.1 13.8 31.7 25.3 2.6 5.3 9.6 28.4 9.6 31.6 0 .9-1.3 2.9-2.8 4.4-2.5 2.4-3.8 2.8-11.8 3.3-6.8.5-10.4.2-15.4-1.1-11.5-3-23.1-4.6-39-5.2-43.5-1.7-64.5 9-76 38.8-10.4 26.9-10.4 73.7 0 95.4 8.1 17 25.3 28.1 49.3 32 10.6 1.7 36.4 2 50.7.5 16.2-1.7 56.2-8.4 57.5-9.7 1.2-1.2 5.7-27.3 7.5-44.3 3.9-36.1 1.5-83.2-6.2-120.5-15.9-77.9-57.3-150.3-113.4-198.5-24.5-21.1-45.7-35.1-74.9-49.5-45.5-22.4-85.7-34.8-132.5-40.7-16-2-64.1-1.7-80 .6-36.9 5.2-63.5 12.5-104.5 28.8C377.2 156.3 335 189.8 265.6 273c-28.5 34.2-42.5 47-61.9 56.5-13.2 6.5-22.5 8.7-37.2 8.7-18.5 0-28.6-4.1-40.2-16.6-22.1-23.6-26.6-68.3-10.8-108.1 16.9-42.5 50-72.6 102.5-92.8 60.4-23.3 167.3-40.1 281-44.1 17.5-.6 97.3.3 115.5 1.4zm-342 254c20 1.9 24 4.1 30.5 17l3 5.9V557h147.8l.7 3.1c.9 4-.2 14.8-2.1 21.9-2.5 8.7-7 16.1-14.2 23.3-8.1 8-16 12.9-26.7 16.5l-8 2.7-85.6.3-85.6.3-.7-43.3c-.3-23.8-.9-90-1.2-147.1l-.7-103.7h15.9c8.7 0 20.9.5 26.9 1zm338.1 3.5c24.2 3.9 42.1 13 57.3 29.1 8.1 8.5 14.8 19.6 17.1 28.4.7 2.5 1.2 8.4 1.1 13.2-.2 25.4-12.6 45.7-34.8 56.9l-8.2 4.2 6.5 2.3c8 2.8 16.2 7.3 23.6 13.2 17.9 14 26 37.4 23 66.2-1.9 18-6.8 27.5-22.2 43.1-9.1 9.2-11.7 11.2-19 14.7-9.1 4.4-21.3 8.1-35 10.8-7.3 1.5-18.6 1.8-82.2 2.1l-73.8.4V503.3c0-127.7-.3-121.4 6-133.1 3.7-7 13.1-18.5 18.2-22.4 7-5.3 17.6-9.9 28.8-12.4 8.6-1.9 81.8-1.8 93.6.1z" />
<path d="M537.4 393.8c-2.9 1.9-3.4 5.5-3.4 26.6V442h30.3c34 0 34.6-.2 42.1-7.6 9.9-9.9 10.2-22.9.8-32.8-2-2.1-5.9-4.9-8.7-6.2-4.9-2.3-5.6-2.4-32.5-2.4-15.1 0-28 .4-28.6.8zM534 530.1v32l32.3-.3c36-.5 38-.8 47.7-7.5 3.1-2.2 6.7-5.9 8.5-8.7 2.8-4.7 3-5.5 3-15.5 0-9.9-.2-11-3-16-4-7.2-9.5-11.7-17.3-14.1-5.8-1.8-9.4-2-38.8-2H534v32.1z" />
</svg>
<p>{% translate "Sign in to LBC Finance" %}</p>
{% if form.errors %}
<div class="alert alert-danger" role="alert">{% translate "Your username and password didn't match. Please try again." %}</div>
{% endif %} {% translate "Username" as i18n_username %} {% translate "Password" as i18n_password %}
<label for="inputUsername" class="sr-only">{{ i18n_username }}</label>
<input type="text" name="username" id="inputUsername" class="form-control" placeholder="{{ i18n_username }}" autocorrect="off" autocapitalize="none" required autofocus />
<label for="inputPassword" class="sr-only">{{ i18n_password }}</label>
<input type="password" name="password" id="inputPassword" class="form-control" placeholder="{{ i18n_password }}" required />
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Sign in" %}</button>
</form>
</body>
</html>
<!DOCTYPE html>
{% load static %} {% load i18n %}
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors" />
<meta name="generator" content="Jekyll v4.1.1" />
<meta name="robots" content="noindex,nofollow" />
<title>{% translate "LBC Finance sign in" %}</title>
<!-- Bootstrap core CSS -->
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet" />
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
<!-- Custom styles for this template -->
<link href="{% static 'signin.css' %}" rel="stylesheet" />
</head>
<body class="text-center">
<form class="form-signin" method="post">
{% csrf_token %}
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" version="1.0" viewBox="0 0 1200 1056">
<path d="M472.5 33c-125.3 3.8-199.4 13.6-264 35.1-26.9 9-56.6 22.8-73.2 34.1C84.7 136.6 50 197.3 40.4 268c-2.3 17-2.3 50.1 0 67 13.8 101.7 73 239.1 167.9 390 47.6 75.7 114.1 165.4 148.5 200.4 57.4 58.3 107.8 89.2 161.7 99.1 12.2 2.3 41.3 3.1 54.5 1.6 51.4-6 112.1-31 169-69.9 70.5-48.1 154-128.9 224.6-217.4 35.6-44.5 73.3-100.3 96.7-142.8 45.7-83 74.9-156.9 86.1-218 6-32.7 8.4-66.5 5.9-83.5-3.7-25.6-10-46.3-20.3-67-9.3-18.7-17.8-30.3-34-46.6-23.9-24.1-46.9-40-86.6-59.8-89.2-44.6-214.8-74.5-354.9-84.6-51.9-3.7-131-5.2-187-3.5zm142 45c78.7 4.4 133.6 11.5 198.5 25.6 118.2 25.6 208.7 66.2 253.6 113.7 38.7 40.8 46 101.7 22.3 184.2-15.6 54.1-48.4 122.6-93.1 194.4-15.4 24.7-39 60.1-50.1 75.1-35.6 48.1-90.2 107.6-141.5 154.2-78.4 71.4-152.7 118-210.7 132.2-20 4.9-21.5 5.1-42 5.1-22.6 0-29.3-1.2-47.2-8.5-35-14.3-87.9-57.1-132.4-107-21.7-24.4-36.7-47.5-42.9-66.4-3.9-12-2.6-20.3 3.6-22.6 11.3-4.3 33.8 3.6 73.6 25.8 52 29.1 72.5 37.1 112.9 44.6 70.9 13.1 135.2 8.7 197.4-13.4 30-10.7 64.7-29.7 92-50.5 37-28.2 73.8-68.3 101.3-110.3 7.4-11.3 23.7-42 22.8-42.9-.2-.2-2.6.7-5.3 2-15.9 8.1-47.4 13.7-76.9 13.7-41.6 0-76.1-8.2-98.4-23.3-6.7-4.6-16.7-14.4-21.5-21.2-16.7-23.5-24.4-59.3-23.2-107.9 1.7-71.3 23.6-113.8 68.6-133.2 17.9-7.8 37-10.7 70.1-10.7 26-.1 36.6 1.2 51.7 6.3 15.2 5.1 26.1 13.8 31.7 25.3 2.6 5.3 9.6 28.4 9.6 31.6 0 .9-1.3 2.9-2.8 4.4-2.5 2.4-3.8 2.8-11.8 3.3-6.8.5-10.4.2-15.4-1.1-11.5-3-23.1-4.6-39-5.2-43.5-1.7-64.5 9-76 38.8-10.4 26.9-10.4 73.7 0 95.4 8.1 17 25.3 28.1 49.3 32 10.6 1.7 36.4 2 50.7.5 16.2-1.7 56.2-8.4 57.5-9.7 1.2-1.2 5.7-27.3 7.5-44.3 3.9-36.1 1.5-83.2-6.2-120.5-15.9-77.9-57.3-150.3-113.4-198.5-24.5-21.1-45.7-35.1-74.9-49.5-45.5-22.4-85.7-34.8-132.5-40.7-16-2-64.1-1.7-80 .6-36.9 5.2-63.5 12.5-104.5 28.8C377.2 156.3 335 189.8 265.6 273c-28.5 34.2-42.5 47-61.9 56.5-13.2 6.5-22.5 8.7-37.2 8.7-18.5 0-28.6-4.1-40.2-16.6-22.1-23.6-26.6-68.3-10.8-108.1 16.9-42.5 50-72.6 102.5-92.8 60.4-23.3 167.3-40.1 281-44.1 17.5-.6 97.3.3 115.5 1.4zm-342 254c20 1.9 24 4.1 30.5 17l3 5.9V557h147.8l.7 3.1c.9 4-.2 14.8-2.1 21.9-2.5 8.7-7 16.1-14.2 23.3-8.1 8-16 12.9-26.7 16.5l-8 2.7-85.6.3-85.6.3-.7-43.3c-.3-23.8-.9-90-1.2-147.1l-.7-103.7h15.9c8.7 0 20.9.5 26.9 1zm338.1 3.5c24.2 3.9 42.1 13 57.3 29.1 8.1 8.5 14.8 19.6 17.1 28.4.7 2.5 1.2 8.4 1.1 13.2-.2 25.4-12.6 45.7-34.8 56.9l-8.2 4.2 6.5 2.3c8 2.8 16.2 7.3 23.6 13.2 17.9 14 26 37.4 23 66.2-1.9 18-6.8 27.5-22.2 43.1-9.1 9.2-11.7 11.2-19 14.7-9.1 4.4-21.3 8.1-35 10.8-7.3 1.5-18.6 1.8-82.2 2.1l-73.8.4V503.3c0-127.7-.3-121.4 6-133.1 3.7-7 13.1-18.5 18.2-22.4 7-5.3 17.6-9.9 28.8-12.4 8.6-1.9 81.8-1.8 93.6.1z" />
<path d="M537.4 393.8c-2.9 1.9-3.4 5.5-3.4 26.6V442h30.3c34 0 34.6-.2 42.1-7.6 9.9-9.9 10.2-22.9.8-32.8-2-2.1-5.9-4.9-8.7-6.2-4.9-2.3-5.6-2.4-32.5-2.4-15.1 0-28 .4-28.6.8zM534 530.1v32l32.3-.3c36-.5 38-.8 47.7-7.5 3.1-2.2 6.7-5.9 8.5-8.7 2.8-4.7 3-5.5 3-15.5 0-9.9-.2-11-3-16-4-7.2-9.5-11.7-17.3-14.1-5.8-1.8-9.4-2-38.8-2H534v32.1z" />
</svg>
<p>{% translate "Sign in to LBC Finance" %}</p>
{% if form.errors %}
<div class="alert alert-danger" role="alert">{% translate "Your username and password didn't match. Please try again." %}</div>
{% endif %} {% translate "Username" as i18n_username %} {% translate "Password" as i18n_password %}
<label for="inputUsername" class="sr-only">{{ i18n_username }}</label>
<input type="text" name="username" id="inputUsername" class="form-control" placeholder="{{ i18n_username }}" autocorrect="off" autocapitalize="none" required autofocus />
<label for="inputPassword" class="sr-only">{{ i18n_password }}</label>
<input type="password" name="password" id="inputPassword" class="form-control" placeholder="{{ i18n_password }}" required />
<button class="btn btn-lg btn-primary btn-block" type="submit">{% translate "Sign in" %}</button>
</form>
</body>
</html>

File diff suppressed because it is too large Load Diff